import { type PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState, } from 'react' import { useTranslation } from 'react-i18next' import { notify } from '@/lib/notify' import { useAudioStore, useAuthStore, useModalStore } from '@/store' import { useGameRoundStore, useGameSessionStore } from '@/store/game' 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) { if (typeof value === 'number') { return Number.isFinite(value) ? value : 0 } if (typeof value !== 'string') { return 0 } const parsed = Number(value) return Number.isFinite(parsed) ? parsed : 0 } function getNextMarqueeId(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 } export function useAnimalVm( animalIds: number[], onSelect?: (animalId: number) => void, ) { const { t } = useTranslation() const authStatus = useAuthStore((state) => state.status) const currentUser = useAuthStore((state) => state.currentUser) const markSoundPlaybackUnlocked = useAudioStore( (state) => state.markSoundPlaybackUnlocked, ) const isLoginModalOpen = useModalStore((state) => state.modals.desktopLogin) const isRegisterModalOpen = useModalStore( (state) => state.modals.desktopRegister, ) const setModalOpen = useModalStore((state) => state.setModalOpen) const activeChipId = useGameRoundStore((state) => state.activeChipId) const chips = useGameRoundStore((state) => state.chips) const clearSelections = useGameRoundStore((state) => state.clearSelections) const roundId = useGameRoundStore((state) => state.round.id) const roundPhase = useGameRoundStore((state) => state.round.phase) const maxSelectionCount = useGameRoundStore( (state) => state.maxSelectionCount, ) const placeBet = useGameRoundStore((state) => state.placeBet) const removeSelectionsForCell = useGameRoundStore( (state) => state.removeSelectionsForCell, ) const selections = useGameRoundStore((state) => state.selections) const connection = useGameSessionStore((state) => state.connection) const tableLimitMax = useGameSessionStore( (state) => state.dashboard.tableLimitMax, ) const requestRealtimeConnection = useGameSessionStore( (state) => state.requestRealtimeConnection, ) const shouldConnectRealtime = useGameSessionStore( (state) => state.shouldConnectRealtime, ) const [marqueeId, setMarqueeId] = useState(() => getNextMarqueeId(animalIds, null), ) const [cellWarning, setCellWarning] = useState<{ cellId: number type: DesktopAnimalWarningType } | null>(null) const [longPressCellId, setLongPressCellId] = useState(null) const longPressTimerRef = useRef(null) const longPressOriginRef = useRef<{ pointerId: number x: number y: number } | null>(null) const suppressedClickRef = useRef<{ cellId: number expiresAt: number } | null>(null) const activeChip = useMemo( () => chips.find((chip) => chip.id === activeChipId) ?? chips[0] ?? null, [activeChipId, chips], ) const balance = parseBalance(currentUser?.coin) const selectionByCell = useMemo(() => { return selections.reduce>( (accumulator, selection) => { const current = accumulator[selection.cellId] ?? { amount: 0, count: 0 } accumulator[selection.cellId] = { amount: current.amount + selection.amount, count: current.count + 1, } return accumulator }, {}, ) }, [selections]) const isRealtimeConnected = connection.status === 'connected' const isRealtimeConnecting = shouldConnectRealtime && (connection.status === 'connecting' || connection.status === 'reconnecting') const showStandbyState = !shouldConnectRealtime || !isRealtimeConnected const isAuthModalOpen = isLoginModalOpen || isRegisterModalOpen const shouldAnimateStandby = showStandbyState && !isAuthModalOpen const hasSubmittedCurrentRound = Boolean(roundId) && currentUser?.lastBetPeriodNo === roundId const lockInteraction = showStandbyState || hasSubmittedCurrentRound || roundPhase !== 'betting' const selectedCellCount = Object.keys(selectionByCell).length const clearLongPressTimer = useCallback(() => { if (longPressTimerRef.current !== null) { window.clearTimeout(longPressTimerRef.current) longPressTimerRef.current = null } }, []) useEffect(() => { if (cellWarning === null) { return } const timerId = window.setTimeout(() => { setCellWarning((currentWarning) => currentWarning?.cellId === cellWarning.cellId && currentWarning.type === cellWarning.type ? null : currentWarning, ) }, 1200) return () => { window.clearTimeout(timerId) } }, [cellWarning]) useEffect(() => { if (!shouldAnimateStandby) { setMarqueeId(null) return } setMarqueeId((currentId) => getNextMarqueeId(animalIds, currentId)) let timerId = 0 const loop = () => { setMarqueeId((currentId) => getNextMarqueeId(animalIds, currentId)) timerId = window.setTimeout(loop, 180 + Math.floor(Math.random() * 220)) } timerId = window.setTimeout(loop, 220) return () => { window.clearTimeout(timerId) } }, [animalIds, shouldAnimateStandby]) useEffect(() => { return () => { clearLongPressTimer() } }, [clearLongPressTimer]) const handleStart = () => { if (authStatus !== 'authenticated') { notify.warning(t('commonUi.toast.loginRequired')) setModalOpen('desktopLogin', true) return } clearSelections() markSoundPlaybackUnlocked() requestRealtimeConnection() } 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) { return } if (onSelect) { onSelect(animalId) return } const hasExistingSelection = Boolean(selectionByCell[animalId]) if (!hasExistingSelection && selectedCellCount >= maxSelectionCount) { setCellWarning({ cellId: animalId, type: 'limit', }) return } 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 && nextSingleBetAmount > tableLimitMax) { setCellWarning({ cellId: animalId, type: 'betLimit', }) return } if (nextTotalBetAmount > balance) { setCellWarning({ cellId: animalId, type: 'balance', }) return } placeBet(animalId) } const handleClearCell = (animalId: number) => { if (lockInteraction || !selectionByCell[animalId]) { return } removeSelectionsForCell(animalId) } const handleLongPressStart = ( animalId: number, event: ReactPointerEvent, ) => { 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) => { 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 { cellWarning, handleClearCell, handleLongPressCancel, handleLongPressEnd, handleLongPressMove, handleLongPressStart, handleSelect, handleStart, isRealtimeConnecting, lockInteraction, longPressCellId, longPressDurationMs: LONG_PRESS_DURATION_MS, marqueeId: shouldAnimateStandby ? marqueeId : null, selectionByCell, showStandbyState, } }