diff --git a/src/assets/game/add.webp b/src/assets/game/add.webp
index 550c504..22b8858 100644
Binary files a/src/assets/game/add.webp and b/src/assets/game/add.webp differ
diff --git a/src/assets/game/reduce.webp b/src/assets/game/reduce.webp
index 22b8858..550c504 100644
Binary files a/src/assets/game/reduce.webp and b/src/assets/game/reduce.webp differ
diff --git a/src/features/auth/schema/auth-schema.ts b/src/features/auth/schema/auth-schema.ts
index baf136f..1260af1 100644
--- a/src/features/auth/schema/auth-schema.ts
+++ b/src/features/auth/schema/auth-schema.ts
@@ -1,12 +1,12 @@
import { z } from 'zod'
-const mobilePhonePattern = /^1[3-9]\d{9}$/
+const malaysiaMobilePhonePattern = /^60\d{1,9}$/
const usernameSchema = z
.string()
.trim()
.min(1, 'auth.validation.username.required')
- .regex(mobilePhonePattern, 'auth.validation.username.invalidPhone')
+ .regex(malaysiaMobilePhonePattern, 'auth.validation.username.invalidPhone')
const passwordSchema = z
.string()
diff --git a/src/features/game/components/desktop/desktop-animal-overlay.tsx b/src/features/game/components/desktop/desktop-animal-overlay.tsx
new file mode 100644
index 0000000..62de547
--- /dev/null
+++ b/src/features/game/components/desktop/desktop-animal-overlay.tsx
@@ -0,0 +1,500 @@
+import { motion, useReducedMotion } from 'motion/react'
+import { useEffect, useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
+import enStopImage from '@/assets/game/en-stop.webp'
+import hostingBg from '@/assets/game/hosting-bg.webp'
+import hostingBtn from '@/assets/game/hosting-btn.webp'
+import winLogo from '@/assets/game/win.webp'
+import winBg from '@/assets/game/win-bg.webp'
+import zhStopImage from '@/assets/game/zh-stop.webp'
+import bigRewardPath from '@/assets/lottie/pc-big-reward.json?url'
+import smallRewardPath from '@/assets/lottie/pc-small-reward.json?url'
+import diamondIcon from '@/assets/system/diamond.webp'
+import refreshIcon from '@/assets/system/refresh.webp'
+import type { FullscreenLottieSource } from '@/components/fullscreen-lottie-overlay.types.ts'
+import { LottiePlayer } from '@/components/lottie-player.tsx'
+import { SmartBackground } from '@/components/smart-background.tsx'
+import { SmartImage } from '@/components/smart-image'
+import { REWARD_OVERLAY_DURATION_MS } from '@/constants'
+import {
+ type BetSelection,
+ FLOWER_IMAGE_BY_ID,
+ groupSelectionsByCell,
+} from '@/features/game/shared'
+import { cn } from '@/lib/utils'
+import { useAuthStore } from '@/store/auth'
+import {
+ type RewardAnimationType,
+ useGameAutoHostingStore,
+ useGameRoundStore,
+} from '@/store/game'
+
+const REWARD_OVERLAY_FADE_OUT_MS = 300
+const REWARD_CHILDREN_FADE_IN_MS = 2_000
+const REWARD_CHILDREN_VISIBLE_MS = 1_000
+
+type RewardChildrenStage = 'hidden' | 'visible' | 'exiting'
+type StopBetItem = {
+ amount: number
+ cellId: number
+ imageUrl: string
+}
+
+interface DesktopAnimalOverlayProps {
+ showStopOverlay: boolean
+}
+
+function easeOutCubic(progress: number) {
+ return 1 - (1 - progress) ** 3
+}
+
+function getAmountMeta(amount: string | null) {
+ if (!amount) {
+ return null
+ }
+
+ const normalizedAmount = amount.replace(/,/g, '')
+ const numericAmount = Number(normalizedAmount)
+
+ if (!Number.isFinite(numericAmount)) {
+ return null
+ }
+
+ const fractionDigits = normalizedAmount.includes('.')
+ ? (normalizedAmount.split('.')[1]?.length ?? 0)
+ : 0
+
+ return {
+ fractionDigits,
+ numericAmount,
+ }
+}
+
+function formatRewardAmount(value: number, fractionDigits: number) {
+ return value.toLocaleString('en-US', {
+ maximumFractionDigits: fractionDigits,
+ minimumFractionDigits: fractionDigits,
+ })
+}
+
+function getRewardSource(
+ rewardType: RewardAnimationType,
+): FullscreenLottieSource | null {
+ if (rewardType === 'small') {
+ return {
+ id: 'pc-small-reward',
+ path: smallRewardPath,
+ loop: false,
+ autoplay: true,
+ }
+ }
+
+ if (rewardType === 'big') {
+ return {
+ id: 'pc-big-reward',
+ path: bigRewardPath,
+ loop: false,
+ autoplay: true,
+ }
+ }
+
+ return null
+}
+
+function formatStopBetAmount(amount: number) {
+ return amount.toLocaleString('en-US', {
+ maximumFractionDigits: 2,
+ minimumFractionDigits: Number.isInteger(amount) ? 0 : 2,
+ })
+}
+
+function getStopBetItems(selections: BetSelection[]) {
+ const groupedSelections = groupSelectionsByCell(selections)
+
+ return Object.entries(groupedSelections)
+ .map(([cellId, meta]) => {
+ const normalizedCellId = Number(cellId)
+
+ return {
+ amount: meta.amount,
+ cellId: normalizedCellId,
+ imageUrl: FLOWER_IMAGE_BY_ID[normalizedCellId]?.animalUrl ?? '',
+ } satisfies StopBetItem
+ })
+ .sort((left, right) => left.cellId - right.cellId)
+}
+
+function StopBetSummary({
+ items,
+ noBetText,
+}: {
+ items: StopBetItem[]
+ noBetText: string
+}) {
+ if (items.length === 0) {
+ return (
+
+ {noBetText}
+
+ )
+ }
+
+ return (
+
+ {items.map((item) => (
+
+
+ {item.imageUrl ? (
+
+ ) : null}
+
+
+
+ {String(item.cellId).padStart(2, '0')}
+
+
+
+
+ {formatStopBetAmount(item.amount)}
+
+
+
+
+ ))}
+
+ )
+}
+
+export function DesktopAnimalOverlay({
+ showStopOverlay,
+}: DesktopAnimalOverlayProps) {
+ const { i18n, t } = useTranslation()
+ const prefersReducedMotion = useReducedMotion()
+ const completedAutoHostingRounds = useGameAutoHostingStore(
+ (state) => state.completedRounds,
+ )
+ const currentRoundId = useGameRoundStore((state) => state.round.id)
+ const lastBetPeriodNo = useAuthStore(
+ (state) => state.currentUser?.lastBetPeriodNo,
+ )
+ const hostingFlag = useGameAutoHostingStore((state) => state.isHosting)
+ const stopHosting = useGameAutoHostingStore((state) => state.stopHosting)
+ const selections = useGameRoundStore((state) => state.selections)
+ const recentSuccessfulSelections = useGameRoundStore(
+ (state) => state.recentSuccessfulSelections,
+ )
+ const rewardType = useGameRoundStore(
+ (state) => state.revealAnimation.rewardType,
+ )
+ const revealPhase = useGameRoundStore((state) => state.revealAnimation.phase)
+ const rewardAmount = useGameRoundStore(
+ (state) => state.revealAnimation.rewardAmount,
+ )
+ const revealKey = useGameRoundStore(
+ (state) => state.revealAnimation.revealKey,
+ )
+ const roundId = useGameRoundStore((state) => state.revealAnimation.roundId)
+ const clearRewardAnimation = useGameRoundStore(
+ (state) => state.clearRewardAnimation,
+ )
+ const [isRewardFadingOut, setIsRewardFadingOut] = useState(false)
+ const [childrenStage, setChildrenStage] =
+ useState('hidden')
+ const [displayRewardAmount, setDisplayRewardAmount] = useState('0')
+ const [hasRenderedReward, setHasRenderedReward] = useState(false)
+ const stopImageSrc = i18n.resolvedLanguage?.startsWith('zh')
+ ? zhStopImage
+ : enStopImage
+ const stopBetItems = useMemo(
+ () =>
+ getStopBetItems(
+ selections.length > 0
+ ? selections
+ : lastBetPeriodNo === currentRoundId
+ ? recentSuccessfulSelections
+ : [],
+ ),
+ [currentRoundId, lastBetPeriodNo, recentSuccessfulSelections, selections],
+ )
+ const rewardAmountMeta = useMemo(
+ () => getAmountMeta(rewardAmount),
+ [rewardAmount],
+ )
+ const rewardSource = useMemo(() => getRewardSource(rewardType), [rewardType])
+ const shouldRenderReward =
+ revealPhase === 'result' && rewardType !== 'none' && rewardSource !== null
+ const overlayAnimationKey = `${rewardType}-${roundId ?? 'round'}-${revealKey ?? 'pending'}`
+ const childTimelineKey = shouldRenderReward ? overlayAnimationKey : 'closed'
+
+ useEffect(() => {
+ if (revealPhase !== 'result') {
+ setHasRenderedReward(false)
+ return
+ }
+
+ if (shouldRenderReward) {
+ setHasRenderedReward(true)
+ }
+ }, [revealPhase, shouldRenderReward])
+
+ useEffect(() => {
+ if (!shouldRenderReward) {
+ return
+ }
+
+ setIsRewardFadingOut(false)
+
+ const fadeTimerId = window.setTimeout(() => {
+ setIsRewardFadingOut(true)
+ }, REWARD_OVERLAY_DURATION_MS)
+ const clearTimerId = window.setTimeout(() => {
+ clearRewardAnimation()
+ }, REWARD_OVERLAY_DURATION_MS + REWARD_OVERLAY_FADE_OUT_MS)
+
+ return () => {
+ window.clearTimeout(fadeTimerId)
+ window.clearTimeout(clearTimerId)
+ }
+ }, [clearRewardAnimation, shouldRenderReward])
+
+ useEffect(() => {
+ if (childTimelineKey === 'closed') {
+ setChildrenStage('hidden')
+ setDisplayRewardAmount('0')
+ return
+ }
+
+ setChildrenStage('hidden')
+ setDisplayRewardAmount(
+ rewardAmountMeta
+ ? formatRewardAmount(0, rewardAmountMeta.fractionDigits)
+ : (rewardAmount ?? '0'),
+ )
+
+ const enterFrameId = window.requestAnimationFrame(() => {
+ setChildrenStage('visible')
+ })
+ const exitTimerId = window.setTimeout(() => {
+ setChildrenStage('exiting')
+ }, REWARD_CHILDREN_FADE_IN_MS + REWARD_CHILDREN_VISIBLE_MS)
+
+ return () => {
+ window.cancelAnimationFrame(enterFrameId)
+ window.clearTimeout(exitTimerId)
+ }
+ }, [childTimelineKey, rewardAmount, rewardAmountMeta])
+
+ useEffect(() => {
+ if (childTimelineKey === 'closed') {
+ return
+ }
+
+ if (!rewardAmountMeta) {
+ setDisplayRewardAmount(rewardAmount ?? '0')
+ return
+ }
+
+ let animationFrameId = 0
+ const startedAt = performance.now()
+
+ const syncRewardAmount = (now: number) => {
+ const progress = Math.min(
+ (now - startedAt) / REWARD_CHILDREN_FADE_IN_MS,
+ 1,
+ )
+ setDisplayRewardAmount(
+ progress >= 1
+ ? formatRewardAmount(
+ rewardAmountMeta.numericAmount,
+ rewardAmountMeta.fractionDigits,
+ )
+ : formatRewardAmount(
+ rewardAmountMeta.numericAmount * easeOutCubic(progress),
+ rewardAmountMeta.fractionDigits,
+ ),
+ )
+
+ if (progress < 1) {
+ animationFrameId = window.requestAnimationFrame(syncRewardAmount)
+ }
+ }
+
+ animationFrameId = window.requestAnimationFrame(syncRewardAmount)
+
+ return () => {
+ window.cancelAnimationFrame(animationFrameId)
+ }
+ }, [childTimelineKey, rewardAmount, rewardAmountMeta])
+
+ if (shouldRenderReward && rewardSource) {
+ const playerKey = `${overlayAnimationKey}-${rewardSource.id}`
+
+ return (
+
+
+
+
+
+
+
+ {displayRewardAmount}
+
+
+
+
+ )
+ }
+
+ if (revealPhase === 'result' && hasRenderedReward) {
+ return (
+
+ )
+ }
+
+ if (hostingFlag) {
+ return (
+
+ {showStopOverlay ? (
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+ {t('game.autoSpin.runningRounds', {
+ count: completedAutoHostingRounds,
+ })}
+
+
+
+ {t('game.actions.stopAuto')}
+
+
+
+
+ )
+ }
+
+ if (showStopOverlay) {
+ return (
+
+ )
+ }
+
+ return null
+}
diff --git a/src/features/game/components/desktop/desktop-animal.tsx b/src/features/game/components/desktop/desktop-animal.tsx
index 5b84316..484731c 100644
--- a/src/features/game/components/desktop/desktop-animal.tsx
+++ b/src/features/game/components/desktop/desktop-animal.tsx
@@ -3,21 +3,18 @@ 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 enStopImage from '@/assets/game/en-stop.webp'
-import hostingBg from '@/assets/game/hosting-bg.webp'
-import hostingBtn from '@/assets/game/hosting-btn.webp'
-import zhStopImage from '@/assets/game/zh-stop.webp'
import diamondIcon from '@/assets/system/diamond.webp'
-import refreshIcon from '@/assets/system/refresh.webp'
-import { SmartBackground } from '@/components/smart-background.tsx'
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 { useGameAutoHostingStore, useGameRoundStore } from '@/store/game'
+import { useGameRoundStore } from '@/store/game'
-const SETTLEMENT_REVEAL_RANDOM_DURATION_MS = 4_000
+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 = 90
const SETTLEMENT_REVEAL_MAX_STEP_MS = 480
@@ -64,7 +61,7 @@ export function DesktopAnimal({
imageClassName,
onSelect,
}: DesktopAnimalProps) {
- const { i18n, t } = useTranslation()
+ const { t } = useTranslation()
const prefersReducedMotion = useReducedMotion()
const animalIds = useMemo(() => FLOWER_IMAGE_LIST.map((item) => item.id), [])
const containerRef = useRef(null)
@@ -77,20 +74,19 @@ export function DesktopAnimal({
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 completedAutoHostingRounds = useGameAutoHostingStore(
- (state) => state.completedRounds,
- )
- const hostingFlag = useGameAutoHostingStore((state) => state.isHosting)
- const stopHosting = useGameAutoHostingStore((state) => state.stopHosting)
const finishRevealAnimation = useGameRoundStore(
(state) => state.finishRevealAnimation,
)
@@ -109,31 +105,46 @@ export function DesktopAnimal({
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'
- const stopImageSrc = i18n.resolvedLanguage?.startsWith('zh')
- ? zhStopImage
- : enStopImage
+
+ 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(revealWinningCellId)
+ 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(() => {
@@ -147,22 +158,28 @@ export function DesktopAnimal({
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)
- setIsRevealHoldingResult(true)
timeoutId = window.setTimeout(() => {
- finishRevealAnimation()
- }, SETTLEMENT_REVEAL_RESULT_HOLD_MS)
+ setIsRevealSettlingResult(false)
+ setIsRevealHoldingResult(true)
+ timeoutId = window.setTimeout(() => {
+ finishRevealAnimation()
+ }, SETTLEMENT_REVEAL_RESULT_HOLD_MS)
+ }, SETTLEMENT_REVEAL_SETTLE_DURATION_MS)
return
}
@@ -181,7 +198,13 @@ export function DesktopAnimal({
return () => {
window.clearTimeout(timeoutId)
}
- }, [animalIds, finishRevealAnimation, revealPhase, revealWinningCellId])
+ }, [
+ animalIds,
+ finishRevealAnimation,
+ revealPhase,
+ revealWinningCellId,
+ shouldHideRevealHighlight,
+ ])
useLayoutEffect(() => {
if (revealCellId === null) {
@@ -230,6 +253,7 @@ export function DesktopAnimal({
const hasPlacedSelection = Boolean(selectionMeta)
const isMarqueeActive = showStandbyState && item.id === marqueeId
const isRevealWinner =
+ !shouldHideRevealHighlight &&
(isRevealResult || isRevealHoldingResult) &&
revealWinningCellId === item.id
const warningType =
@@ -238,7 +262,9 @@ export function DesktopAnimal({
const warningLabel =
warningType === 'balance'
? t('gameDesktop.animal.insufficientBalanceRecharge')
- : t('gameDesktop.animal.selectionLimitReached')
+ : warningType === 'betLimit'
+ ? t('gameDesktop.animal.betLimitExceeded')
+ : t('gameDesktop.animal.selectionLimitReached')
return (
) : null}
- {showStopOverlay && !hostingFlag ? (
-
-
-
- ) : null}
-
- {hostingFlag ? (
-
- {showStopOverlay ? (
-
- ) : null}
-
-
-
-
-
-
-
- {t('game.autoSpin.runningRounds', {
- count: completedAutoHostingRounds,
- })}
-
-
-
- {t('game.actions.stopAuto')}
-
-
-
-
- ) : null}
+
+
{showStandbyState ? (