Files
36-character-flower/src/features/game/components/desktop/desktop-animal.tsx
JiaJun 3efcb3bba6 refactor: 重构多语言和走马灯
- 删除 src/locales/en-US/common.ts 文件中的所有国际化文案
- 删除 src/locales/id-ID/common.ts 文件中的所有国际化文案
- 移除与游戏、认证、UI组件相关的多语言配置项
- 清理导航、游戏大厅、语言选择等界面的翻译内容
- 移除登录注册表单、验证规则等认证相关文案
- 删除支付、提款、钱包记录等财务功能翻译项
2026-06-02 14:31:49 +08:00

550 lines
23 KiB
TypeScript

import { TriangleAlert } from 'lucide-react'
import { motion, useReducedMotion } from 'motion/react'
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import animalBorderImage from '@/assets/game/animal-border.webp'
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 { RoundBettingStartAlert } from '@/features/game/components/shared/round-betting-start-alert.tsx'
import { useAnimalVm } from '@/features/game/hooks/use-animal-vm'
import { FLOWER_IMAGE_LIST } from '@/features/game/shared'
import { cn } from '@/lib/utils'
import { useAuthStore } from '@/store/auth'
import { useGameRoundStore } from '@/store/game'
const SETTLEMENT_REVEAL_RANDOM_DURATION_MS = 3_200
const SETTLEMENT_REVEAL_SETTLE_DURATION_MS = 800
const SETTLEMENT_REVEAL_RESULT_HOLD_MS = 1_000
const SETTLEMENT_REVEAL_MIN_STEP_MS = 180
const SETTLEMENT_REVEAL_MAX_STEP_MS = 960
function getRandomAnimalId(ids: number[], currentId: number | null) {
if (ids.length === 0) {
return null
}
if (ids.length === 1) {
return ids[0] ?? null
}
let nextId = currentId
while (nextId === currentId) {
nextId = ids[Math.floor(Math.random() * ids.length)] ?? currentId
}
return nextId
}
function getSettlementRevealStepDelay(progress: number) {
const clampedProgress = Math.min(Math.max(progress, 0), 1)
const easedProgress = clampedProgress ** 1.65
return (
SETTLEMENT_REVEAL_MIN_STEP_MS +
(SETTLEMENT_REVEAL_MAX_STEP_MS - SETTLEMENT_REVEAL_MIN_STEP_MS) *
easedProgress
)
}
interface DesktopAnimalProps {
className?: string
itemClassName?: string
imageClassName?: string
onSelect?: (animalId: number) => void
}
export function DesktopAnimal({
className,
itemClassName,
imageClassName,
onSelect,
}: DesktopAnimalProps) {
const { t } = useTranslation()
const prefersReducedMotion = useReducedMotion()
const animalIds = useMemo(() => FLOWER_IMAGE_LIST.map((item) => item.id), [])
const containerRef = useRef<HTMLElement | null>(null)
const cellRefs = useRef(new Map<number, HTMLButtonElement>())
const [revealCellId, setRevealCellId] = useState<number | null>(null)
const [revealFrame, setRevealFrame] = useState<{
height: number
left: number
top: number
width: number
} | null>(null)
const [isRevealHoldingResult, setIsRevealHoldingResult] = useState(false)
const [isRevealSettlingResult, setIsRevealSettlingResult] = useState(false)
const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
const revealWinningCellId = useGameRoundStore(
(state) => state.revealAnimation.winningCellId,
)
const revealRewardType = useGameRoundStore(
(state) => state.revealAnimation.rewardType,
)
const roundPhase = useGameRoundStore((state) => state.round.phase)
const roundId = useGameRoundStore((state) => state.round.id)
const lastBetPeriodNo = useAuthStore(
(state) => state.currentUser?.lastBetPeriodNo,
)
const finishRevealAnimation = useGameRoundStore(
(state) => state.finishRevealAnimation,
)
const {
cellWarning,
handleSelect,
handleStart,
isRealtimeConnecting,
lockInteraction,
marqueeId,
selectionByCell,
showStandbyState,
} = useAnimalVm(animalIds, onSelect)
const isRevealRunning =
revealPhase === 'spinning' ||
(revealPhase === 'stopping' && !isRevealHoldingResult)
const isRevealResult = revealPhase === 'result'
const [hasRewardOverlayStarted, setHasRewardOverlayStarted] = useState(false)
const shouldHideRevealHighlight =
revealPhase === 'result' &&
(revealRewardType !== 'none' || hasRewardOverlayStarted)
const hasSubmittedCurrentRound =
roundPhase === 'betting' && Boolean(roundId) && lastBetPeriodNo === roundId
const showStopOverlay =
hasSubmittedCurrentRound ||
roundPhase === 'locked' ||
roundPhase === 'revealing'
useEffect(() => {
if (revealPhase === 'idle') {
setHasRewardOverlayStarted(false)
return
}
if (revealPhase === 'result' && revealRewardType !== 'none') {
setHasRewardOverlayStarted(true)
}
}, [revealPhase, revealRewardType])
useEffect(() => {
if (revealPhase === 'idle') {
setRevealCellId(null)
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
return
}
if (revealPhase === 'result') {
setRevealCellId(shouldHideRevealHighlight ? null : revealWinningCellId)
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
return
}
if (revealPhase === 'spinning') {
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
const intervalId = window.setInterval(() => {
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
}, 70)
return () => {
window.clearInterval(intervalId)
}
}
if (revealWinningCellId === null) {
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
return
}
const startedAt = performance.now()
let timeoutId = 0
setIsRevealHoldingResult(false)
setIsRevealSettlingResult(false)
const step = () => {
const elapsedMs = performance.now() - startedAt
if (elapsedMs >= SETTLEMENT_REVEAL_RANDOM_DURATION_MS) {
setIsRevealSettlingResult(true)
setRevealCellId(revealWinningCellId)
timeoutId = window.setTimeout(() => {
setIsRevealSettlingResult(false)
setIsRevealHoldingResult(true)
timeoutId = window.setTimeout(() => {
finishRevealAnimation()
}, SETTLEMENT_REVEAL_RESULT_HOLD_MS)
}, SETTLEMENT_REVEAL_SETTLE_DURATION_MS)
return
}
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
const progress = elapsedMs / SETTLEMENT_REVEAL_RANDOM_DURATION_MS
const nextDelayMs = getSettlementRevealStepDelay(progress)
const remainingMs = SETTLEMENT_REVEAL_RANDOM_DURATION_MS - elapsedMs
timeoutId = window.setTimeout(step, Math.min(nextDelayMs, remainingMs))
}
setRevealCellId((currentId) => getRandomAnimalId(animalIds, currentId))
timeoutId = window.setTimeout(step, SETTLEMENT_REVEAL_MIN_STEP_MS)
return () => {
window.clearTimeout(timeoutId)
}
}, [
animalIds,
finishRevealAnimation,
revealPhase,
revealWinningCellId,
shouldHideRevealHighlight,
])
useLayoutEffect(() => {
if (revealCellId === null) {
setRevealFrame(null)
return
}
const syncRevealFrame = () => {
const container = containerRef.current
const cell = cellRefs.current.get(revealCellId)
if (!container || !cell) {
setRevealFrame(null)
return
}
const containerRect = container.getBoundingClientRect()
const cellRect = cell.getBoundingClientRect()
setRevealFrame({
height: cellRect.height,
left: cellRect.left - containerRect.left,
top: cellRect.top - containerRect.top,
width: cellRect.width,
})
}
syncRevealFrame()
window.addEventListener('resize', syncRevealFrame)
return () => {
window.removeEventListener('resize', syncRevealFrame)
}
}, [revealCellId])
return (
<section
ref={containerRef}
className={cn(
'relative grid w-full grid-cols-6 gap-design-5 overflow-hidden common-neon-inset',
className,
)}
>
{FLOWER_IMAGE_LIST.map((item) => {
const selectionMeta = selectionByCell[item.id]
const hasPlacedSelection = Boolean(selectionMeta)
const isMarqueeActive = showStandbyState && item.id === marqueeId
const isRevealWinner =
!shouldHideRevealHighlight &&
(isRevealResult || isRevealHoldingResult) &&
revealWinningCellId === item.id
const warningType =
cellWarning?.cellId === item.id ? cellWarning.type : null
const showCellWarning = warningType !== null
const warningLabel =
warningType === 'balance'
? t('gameDesktop.animal.insufficientBalanceRecharge')
: warningType === 'betLimit'
? t('gameDesktop.animal.betLimitExceeded')
: t('gameDesktop.animal.selectionLimitReached')
return (
<motion.button
key={item.id}
ref={(node) => {
if (node) {
cellRefs.current.set(item.id, node)
} else {
cellRefs.current.delete(item.id)
}
}}
type="button"
disabled={lockInteraction || showStopOverlay}
onClick={() => handleSelect(item.id)}
animate={
showCellWarning
? {
rotate: [0, -1.8, 1.4, -1, 0.6, 0],
scale: [1, 1.015, 0.992, 1.01, 1],
x: [0, -2, 2, -1, 1, 0],
}
: {
rotate: 0,
scale: 1,
x: 0,
}
}
transition={
showCellWarning
? {
duration: 0.46,
ease: 'easeInOut',
}
: { duration: 0.16, ease: 'easeOut' }
}
className={cn(
'relative flex h-design-112 flex-col items-center justify-center overflow-hidden rounded-[calc(var(--design-unit)*18)] border border-transparent transition-[transform,border-color,box-shadow,opacity] duration-150',
lockInteraction
? 'cursor-not-allowed opacity-90'
: 'cursor-pointer hover:-translate-y-[1px]',
isMarqueeActive &&
'border-[rgba(121,255,250,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(85,255,247,0.98),0_0_calc(var(--design-unit)*34)_rgba(39,245,255,0.88),inset_0_0_calc(var(--design-unit)*26)_rgba(112,255,248,0.34)]',
isRevealRunning &&
'border-[rgba(104,255,249,0.76)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(68,244,255,0.46),0_0_calc(var(--design-unit)*22)_rgba(37,214,255,0.28),inset_0_0_calc(var(--design-unit)*16)_rgba(115,255,247,0.18)] brightness-115 saturate-125',
isRevealWinner &&
'border-[rgba(121,255,250,0.72)] shadow-[0_0_calc(var(--design-unit)*12)_rgba(81,248,255,0.54),0_0_calc(var(--design-unit)*22)_rgba(30,199,255,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(125,255,249,0.24)] brightness-110 saturate-120',
showCellWarning &&
'border-[rgba(255,92,92,1)] shadow-[0_0_calc(var(--design-unit)*18)_rgba(255,88,88,0.56),0_0_calc(var(--design-unit)*28)_rgba(255,44,44,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(255,126,126,0.3)]',
!showStandbyState && !hasPlacedSelection && 'opacity-95',
itemClassName,
)}
>
<SmartImage
src={animalBorderImage}
alt=""
aria-hidden="true"
priority
showSkeleton={false}
className="pointer-events-none absolute inset-0 z-20 h-full w-full"
imgClassName="object-fill"
/>
<span className="pointer-events-none absolute left-design-24 top-design-16 z-30 text-design-32 font-bold leading-none text-[#4BFFFE]">
{String(item.id).padStart(2, '0')}
</span>
<motion.span
aria-hidden="true"
animate={
showCellWarning
? {
opacity: [0.45, 1, 0.6, 1, 0.82],
}
: undefined
}
transition={
showCellWarning
? {
duration: 0.52,
ease: 'easeInOut',
}
: undefined
}
className={cn(
'pointer-events-none absolute inset-[calc(var(--design-unit)*2)] rounded-[calc(var(--design-unit)*15)] opacity-0 transition-opacity duration-150',
isMarqueeActive &&
'bg-[radial-gradient(circle_at_center,rgba(129,255,250,0.48)_0%,rgba(94,255,247,0.18)_38%,rgba(43,236,255,0.08)_56%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(119,255,249,0.98),0_0_calc(var(--design-unit)*28)_rgba(53,246,255,0.9),0_0_calc(var(--design-unit)*44)_rgba(37,241,255,0.58),inset_0_0_calc(var(--design-unit)*20)_rgba(163,255,250,0.52)]',
isRevealRunning &&
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.36)_0%,rgba(77,244,255,0.16)_40%,rgba(27,183,255,0.07)_68%,transparent_88%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(95,249,255,0.46),inset_0_0_calc(var(--design-unit)*18)_rgba(151,255,250,0.24)]',
isRevealWinner &&
'bg-[radial-gradient(circle_at_center,rgba(128,255,250,0.34)_0%,rgba(67,226,255,0.16)_38%,rgba(25,131,255,0.07)_58%,transparent_76%)] opacity-100 shadow-[0_0_calc(var(--design-unit)*12)_rgba(92,248,255,0.42),inset_0_0_calc(var(--design-unit)*18)_rgba(126,255,250,0.24)]',
showCellWarning &&
'bg-[radial-gradient(circle_at_center,rgba(255,106,106,0.34)_0%,rgba(255,58,58,0.18)_42%,rgba(108,0,0,0.2)_78%,transparent_100%)] opacity-100',
)}
/>
{!showStandbyState && !hasPlacedSelection ? (
<span
aria-hidden="true"
className="pointer-events-none absolute inset-[calc(var(--design-unit)*2)] z-20 rounded-[calc(var(--design-unit)*15)] bg-[rgba(4,16,24,0.52)] shadow-[inset_0_0_calc(var(--design-unit)*20)_rgba(3,9,14,0.56)]"
/>
) : null}
<SmartImage
src={item.animalUrl}
alt={`animal-${item.id}`}
className={cn(
'absolute left-[1.5%] right-[1.5%] top-[2.9%] bottom-[2.9%] z-10 overflow-hidden rounded-[calc(var(--design-unit)*14)]',
isRevealRunning &&
'brightness-115 saturate-125 drop-shadow-[0_0_calc(var(--design-unit)*8)_rgba(101,250,255,0.42)]',
isRevealWinner &&
'brightness-110 saturate-120 drop-shadow-[0_0_calc(var(--design-unit)*9)_rgba(106,250,255,0.44)]',
imageClassName,
)}
imgClassName="object-fill"
/>
{showCellWarning ? (
<motion.span
initial={{ opacity: 0, scale: 0.94 }}
animate={{
opacity: [0.2, 1, 0.92],
scale: [0.96, 1.02, 1],
boxShadow: [
'inset 0 0 calc(var(--design-unit)*10) rgba(255,108,108,0.16), 0 0 calc(var(--design-unit)*8) rgba(255,60,60,0.12)',
'inset 0 0 calc(var(--design-unit)*20) rgba(255,108,108,0.28), 0 0 calc(var(--design-unit)*20) rgba(255,60,60,0.28)',
'inset 0 0 calc(var(--design-unit)*16) rgba(255,108,108,0.22), 0 0 calc(var(--design-unit)*18) rgba(255,60,60,0.2)',
],
}}
transition={{ duration: 0.5, ease: 'easeOut' }}
className="pointer-events-none absolute inset-[calc(var(--design-unit)*3)] z-30 flex flex-col items-center justify-center gap-design-8 rounded-[calc(var(--design-unit)*15)] border border-[rgba(255,126,126,0.9)] bg-[rgba(61,0,0,0.58)] px-design-10 py-design-10 text-center"
>
<motion.span
animate={{
opacity: [0.72, 1, 0.92],
scale: [0.94, 1.08, 1],
}}
transition={{ duration: 0.38, ease: 'easeOut' }}
className="flex h-design-28 w-design-28 items-center justify-center"
>
<TriangleAlert className="h-design-28 w-design-28 text-[#FFD0D0] drop-shadow-[0_0_calc(var(--design-unit)*8)_rgba(255,92,92,0.45)]" />
</motion.span>
<motion.span
animate={{
opacity: [0.7, 1, 0.96],
scale: [0.98, 1.03, 1],
}}
transition={{ duration: 0.42, ease: 'easeOut' }}
className="text-design-16 font-bold leading-tight tracking-[0.04em] text-[#FFE0E0] [text-shadow:0_0_calc(var(--design-unit)*10)_rgba(255,132,132,0.42)]"
>
{warningLabel}
</motion.span>
</motion.span>
) : null}
{hasPlacedSelection ? (
<span className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
<span className="flex min-w-design-96 items-center justify-center gap-design-4 rounded-full border border-[rgba(162,242,255,0.48)] bg-[linear-gradient(180deg,rgba(7,23,34,0.88),rgba(5,14,22,0.96))] px-design-10 py-design-6 shadow-[0_0_calc(var(--design-unit)*18)_rgba(70,245,255,0.18)]">
<SmartImage
src={diamondIcon}
alt="diamond"
className="h-design-24 w-design-24 shrink-0 object-contain"
/>
<span className="text-design-18 font-semibold leading-none tracking-[0.06em] text-[#D8FBFF]">
{selectionMeta.amount}
</span>
</span>
</span>
) : null}
</motion.button>
)
})}
{revealFrame ? (
<div
aria-hidden="true"
className={cn(
'pointer-events-none absolute z-40 transform-gpu transition-[height,transform,width]',
prefersReducedMotion
? 'duration-0'
: isRevealSettlingResult
? 'duration-[800ms] ease-[cubic-bezier(0.16,1,0.3,1)]'
: 'duration-[160ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
)}
style={{
height: revealFrame.height,
transform: `translate(${revealFrame.left}px, ${revealFrame.top}px)`,
width: revealFrame.width,
}}
>
<div
className="gold-reveal-glow rounded-[calc(var(--design-unit)*16)]"
style={
prefersReducedMotion
? {
animation: 'none',
opacity: 0.36,
transform: 'scale(1)',
}
: undefined
}
/>
<div className="gold-reveal-static-border rounded-[calc(var(--design-unit)*16)]" />
<div
className="gold-reveal-shell rounded-[calc(var(--design-unit)*16)]"
style={prefersReducedMotion ? { animation: 'none' } : undefined}
/>
</div>
) : null}
<DesktopAnimalOverlay showStopOverlay={showStopOverlay} />
<RoundBettingStartAlert />
{showStandbyState ? (
<button
type="button"
onClick={handleStart}
aria-busy={isRealtimeConnecting}
className="group absolute inset-0 z-10 flex cursor-pointer items-center justify-center overflow-hidden bg-[rgba(3,13,20,0.66)]"
>
<div className="relative flex min-w-design-260 flex-col items-center gap-design-10 rounded-[calc(var(--design-unit)*22)] border border-[rgba(111,255,247,0.56)] bg-[linear-gradient(180deg,rgba(8,30,42,0.94),rgba(4,14,20,0.96))] px-design-32 py-design-18 text-center shadow-[0_0_calc(var(--design-unit)*18)_rgba(70,245,255,0.38),0_0_calc(var(--design-unit)*42)_rgba(19,210,232,0.22),inset_0_0_calc(var(--design-unit)*18)_rgba(120,255,249,0.12)] transition-[transform,box-shadow,border-color] duration-200 group-hover:-translate-y-[1px] group-hover:border-[rgba(141,255,250,0.82)] group-hover:shadow-[0_0_calc(var(--design-unit)*24)_rgba(88,247,255,0.5),0_0_calc(var(--design-unit)*52)_rgba(32,228,255,0.32),inset_0_0_calc(var(--design-unit)*18)_rgba(145,255,251,0.16)]">
<span className="pointer-events-none absolute inset-[1px] rounded-[calc(var(--design-unit)*22)] border border-[rgba(226,255,255,0.1)]" />
<span className="pointer-events-none absolute inset-x-design-14 top-design-10 h-design-18 rounded-full bg-[linear-gradient(180deg,rgba(255,255,255,0.16),rgba(255,255,255,0))] opacity-70" />
<span className="text-design-14 uppercase tracking-[0.44em] text-[rgba(132,255,248,0.72)]">
{isRealtimeConnecting
? t('gameDesktop.animal.loading')
: t('gameDesktop.animal.tapToEnter')}
</span>
<div className="flex items-center gap-design-10">
{isRealtimeConnecting ? (
<span className="relative flex h-design-24 w-design-24 items-center justify-center">
<motion.span
animate={{ rotate: 360 }}
transition={{
duration: 1.2,
repeat: Number.POSITIVE_INFINITY,
ease: 'linear',
}}
className="absolute inset-0 rounded-full border-2 border-[rgba(119,255,250,0.22)] border-t-[rgba(119,255,250,0.98)] border-r-[rgba(119,255,250,0.56)]"
/>
<motion.span
animate={{ scale: [0.85, 1, 0.85], opacity: [0.6, 1, 0.6] }}
transition={{
duration: 1.2,
repeat: Number.POSITIVE_INFINITY,
ease: 'easeInOut',
}}
className="h-design-8 w-design-8 rounded-full bg-[#BFFFFD] shadow-[0_0_calc(var(--design-unit)*10)_rgba(114,255,249,0.72)]"
/>
</span>
) : (
<span className="h-design-10 w-design-10 rounded-full bg-[rgba(126,255,248,0.92)] shadow-[0_0_calc(var(--design-unit)*10)_rgba(114,255,249,0.62)]" />
)}
<span className="text-design-28 font-semibold tracking-[0.18em] text-[#E0FFFF]">
{isRealtimeConnecting
? t('gameDesktop.animal.loading')
: t('gameDesktop.animal.getStart')}
</span>
</div>
<div className="flex items-center gap-design-4">
{[0, 1, 2].map((index) => (
<motion.span
key={index}
animate={
isRealtimeConnecting
? { opacity: [0.28, 1, 0.28], y: [0, -2, 0] }
: { opacity: 0.7 }
}
transition={
isRealtimeConnecting
? {
duration: 0.9,
repeat: Number.POSITIVE_INFINITY,
ease: 'easeInOut',
delay: index * 0.15,
}
: undefined
}
className="h-design-4 w-design-4 rounded-full bg-[rgba(145,255,249,0.86)] shadow-[0_0_calc(var(--design-unit)*8)_rgba(114,255,249,0.48)]"
/>
))}
</div>
</div>
</button>
) : null}
</section>
)
}