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(null) const cellRefs = useRef(new Map()) const [revealCellId, setRevealCellId] = useState(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 (
{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 ( { 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, )} > ) })} {revealFrame ? (
) }