refactor: 重构中奖和推送大奖事件,中奖过度动画,和开奖动画

This commit is contained in:
JiaJun
2026-05-27 14:57:08 +08:00
parent 046f250ce3
commit 2b2b86a73d
48 changed files with 2471 additions and 422 deletions

View File

@@ -0,0 +1,90 @@
import { create } from 'zustand'
import type { BetSelection } from '@/features/game/shared'
export interface AutoHostingStopRules {
stopIfBalanceBelow: {
amount: number
enabled: boolean
}
stopIfSingleWinAbove: {
amount: number
enabled: boolean
}
stopOnJackpot: boolean
}
interface StartAutoHostingInput {
balanceAfterBet: number | null
rules: AutoHostingStopRules
selections: BetSelection[]
}
export interface GameAutoHostingStoreState {
balanceAfterBet: number | null
completedRounds: number
isHosting: boolean
lastSubmittedRoundId: string | null
rules: AutoHostingStopRules
selections: BetSelection[]
markRoundSubmitted: (roundId: string, balanceAfterBet: number | null) => void
startHosting: (input: StartAutoHostingInput) => void
stopHosting: () => void
}
const DEFAULT_AUTO_HOSTING_RULES: AutoHostingStopRules = {
stopIfBalanceBelow: {
amount: 0,
enabled: false,
},
stopIfSingleWinAbove: {
amount: 50_000,
enabled: false,
},
stopOnJackpot: false,
}
export const useGameAutoHostingStore = create<GameAutoHostingStoreState>()(
(set) => ({
balanceAfterBet: null,
completedRounds: 0,
isHosting: false,
lastSubmittedRoundId: null,
rules: DEFAULT_AUTO_HOSTING_RULES,
selections: [],
markRoundSubmitted: (roundId, balanceAfterBet) => {
set((state) => {
if (!state.isHosting || state.lastSubmittedRoundId === roundId) {
return state
}
return {
balanceAfterBet,
completedRounds: state.completedRounds + 1,
lastSubmittedRoundId: roundId,
}
})
},
startHosting: ({ balanceAfterBet, rules, selections }) => {
set({
balanceAfterBet,
completedRounds: 0,
isHosting: true,
lastSubmittedRoundId: null,
rules,
selections: selections.map((selection) => ({
...selection,
})),
})
},
stopHosting: () => {
set({
balanceAfterBet: null,
completedRounds: 0,
isHosting: false,
lastSubmittedRoundId: null,
selections: [],
})
},
}),
)

View File

@@ -35,9 +35,13 @@ export type RevealAnimationPhase = 'idle' | 'spinning' | 'stopping' | 'result'
export type RewardAnimationType = 'none' | 'small' | 'big'
export interface RevealAnimationState {
pendingRewardAmount: string | null
pendingRewardKey: string | null
pendingRewardRoundId: string | null
pendingRewardType: RewardAnimationType
phase: RevealAnimationPhase
revealKey: string | null
rewardAmount: string | null
rewardType: RewardAnimationType
roundId: string | null
winningCellId: number | null
@@ -45,15 +49,39 @@ export interface RevealAnimationState {
function createIdleRevealAnimation(): RevealAnimationState {
return {
pendingRewardAmount: null,
pendingRewardKey: null,
pendingRewardRoundId: null,
pendingRewardType: 'none',
phase: 'idle',
revealKey: null,
rewardAmount: null,
rewardType: 'none',
roundId: null,
winningCellId: null,
}
}
function createIdleRevealAnimationPreservingReward(
current: RevealAnimationState,
): RevealAnimationState {
if (current.rewardType === 'none' && current.pendingRewardType === 'none') {
return createIdleRevealAnimation()
}
return {
...createIdleRevealAnimation(),
pendingRewardAmount: current.pendingRewardAmount,
pendingRewardKey: current.pendingRewardKey,
pendingRewardRoundId: current.pendingRewardRoundId,
pendingRewardType: current.pendingRewardType,
revealKey: current.revealKey,
rewardAmount: current.rewardAmount,
rewardType: current.rewardType,
roundId: current.roundId,
}
}
function resolveRecentActiveChipId(
chips: Chip[],
selections: BetSelection[],
@@ -83,7 +111,6 @@ export interface GameRoundStoreState extends GameRoundSlice {
placeBet: (cellId: number) => void
playPreparedRevealAnimation: (roundId?: string | null) => void
prepareRevealAnimation: (input: {
hasSmallReward: boolean
revealKey: string
roundId: string
winningCellId: number
@@ -95,7 +122,13 @@ export interface GameRoundStoreState extends GameRoundSlice {
setRecentSuccessfulSelections: (selections: BetSelection[]) => void
selectChip: (chipId: string) => void
setPhase: (phase: RoundPhase) => void
showJackpotReward: (roundId?: string | null) => void
setPendingBetWinReward: (input: {
isJackpot: boolean
revealKey: string
roundId?: string | null
totalWin: string
winningCellId?: number | null
}) => void
syncRound: (round: Partial<RoundSnapshot>) => void
upsertSelections: (selections: BetSelection[]) => void
}
@@ -130,6 +163,11 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
set((state) => ({
revealAnimation: {
...state.revealAnimation,
pendingRewardAmount: null,
pendingRewardKey: null,
pendingRewardRoundId: null,
pendingRewardType: 'none',
rewardAmount: null,
rewardType: 'none',
},
}))
@@ -143,16 +181,31 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
return state
}
const rewardType =
state.revealAnimation.rewardType === 'big'
? 'big'
: state.revealAnimation.pendingRewardType
const hasPendingReward =
state.revealAnimation.pendingRewardType !== 'none' &&
state.revealAnimation.pendingRewardRoundId ===
state.revealAnimation.roundId
return {
revealAnimation: {
...state.revealAnimation,
pendingRewardAmount: null,
pendingRewardKey: null,
pendingRewardRoundId: null,
pendingRewardType: 'none',
phase: 'result',
rewardType,
revealKey: hasPendingReward
? state.revealAnimation.pendingRewardKey
: state.revealAnimation.revealKey,
rewardAmount: hasPendingReward
? state.revealAnimation.pendingRewardAmount
: state.revealAnimation.rewardAmount,
rewardType: hasPendingReward
? state.revealAnimation.pendingRewardType
: state.revealAnimation.rewardType,
roundId: hasPendingReward
? state.revealAnimation.pendingRewardRoundId
: state.revealAnimation.roundId,
},
}
})
@@ -170,7 +223,7 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
maxSelectionCount: snapshot.maxSelectionCount,
revealAnimation:
snapshot.round.phase === 'betting' || snapshot.round.phase === 'waiting'
? createIdleRevealAnimation()
? createIdleRevealAnimationPreservingReward(state.revealAnimation)
: state.revealAnimation,
round: snapshot.round,
selections: snapshot.selections,
@@ -235,34 +288,17 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
}
})
},
prepareRevealAnimation: ({
hasSmallReward,
revealKey,
roundId,
winningCellId,
}) => {
prepareRevealAnimation: ({ revealKey, roundId, winningCellId }) => {
set((state) => {
if (state.revealAnimation.revealKey === revealKey) {
return state
}
const pendingRewardType =
state.revealAnimation.pendingRewardType === 'big'
? 'big'
: hasSmallReward
? 'small'
: 'none'
return {
revealAnimation: {
...state.revealAnimation,
pendingRewardType,
phase: 'idle',
revealKey,
rewardType:
state.revealAnimation.rewardType === 'big'
? 'big'
: state.revealAnimation.rewardType,
roundId,
winningCellId,
},
@@ -346,24 +382,53 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
}
if (phase === 'betting' || phase === 'waiting') {
nextState.revealAnimation = createIdleRevealAnimation()
nextState.revealAnimation = createIdleRevealAnimationPreservingReward(
state.revealAnimation,
)
}
return nextState
})
},
showJackpotReward: (roundId) => {
setPendingBetWinReward: ({
isJackpot,
revealKey,
roundId,
totalWin,
winningCellId,
}) => {
set((state) => {
const nextRoundId =
roundId ?? state.round.id ?? state.revealAnimation.roundId
const isResultReady = state.revealAnimation.phase === 'result'
const nextWinningCellId =
winningCellId ?? state.revealAnimation.winningCellId
const rewardType = isJackpot ? 'big' : 'small'
const shouldQueueReward =
state.revealAnimation.phase !== 'result' ||
state.revealAnimation.roundId !== nextRoundId
if (shouldQueueReward) {
return {
revealAnimation: {
...state.revealAnimation,
pendingRewardAmount: totalWin,
pendingRewardKey: revealKey,
pendingRewardRoundId: nextRoundId,
pendingRewardType: rewardType,
roundId: nextRoundId,
winningCellId: nextWinningCellId,
},
}
}
return {
revealAnimation: {
...state.revealAnimation,
pendingRewardType: 'big',
rewardType: isResultReady ? 'big' : state.revealAnimation.rewardType,
revealKey,
rewardAmount: totalWin,
rewardType,
roundId: nextRoundId,
winningCellId: nextWinningCellId,
},
}
})
@@ -392,7 +457,9 @@ export const useGameRoundStore = create<GameRoundStoreState>()((set) => ({
nextRound.id !== previousRound.id &&
(nextRound.phase === 'betting' || nextRound.phase === 'waiting')
) {
nextState.revealAnimation = createIdleRevealAnimation()
nextState.revealAnimation = createIdleRevealAnimationPreservingReward(
state.revealAnimation,
)
}
return nextState

View File

@@ -18,10 +18,25 @@ type GameSessionSlice = Pick<
'announcements' | 'connection' | 'dashboard'
>
const MAX_JACKPOT_BROADCAST_COUNT = 20
export interface JackpotBroadcastItem {
id: string
message: string
periodNo: string
receivedAt: string
totalWin: string
userId: number
}
type JackpotBroadcastInput = Omit<JackpotBroadcastItem, 'receivedAt'>
export interface GameSessionStoreState extends GameSessionSlice {
dismissAnnouncement: (announcementId: string) => void
hydrateSession: (snapshot: GameSessionSlice) => void
jackpotBroadcasts: JackpotBroadcastItem[]
markAnnouncementRead: (announcementId: string) => void
pushJackpotBroadcasts: (broadcasts: JackpotBroadcastInput[]) => void
requestRealtimeConnection: () => void
resetRealtimeConnectionRequest: () => void
shouldConnectRealtime: boolean
@@ -43,6 +58,7 @@ function createInitialSessionState(): GameSessionSlice {
export const useGameSessionStore = create<GameSessionStoreState>()((set) => ({
...createInitialSessionState(),
jackpotBroadcasts: [],
shouldConnectRealtime: false,
dismissAnnouncement: (announcementId) => {
set((state) => ({
@@ -63,6 +79,7 @@ export const useGameSessionStore = create<GameSessionStoreState>()((set) => ({
hydrateSession: (snapshot) => {
set((state) => ({
...snapshot,
jackpotBroadcasts: state.jackpotBroadcasts,
shouldConnectRealtime: state.shouldConnectRealtime,
}))
},
@@ -76,6 +93,35 @@ export const useGameSessionStore = create<GameSessionStoreState>()((set) => ({
},
}))
},
pushJackpotBroadcasts: (broadcasts) => {
if (broadcasts.length === 0) {
return
}
set((state) => {
const existingIds = new Set(
state.jackpotBroadcasts.map((item) => item.id),
)
const receivedAt = new Date().toISOString()
const nextBroadcasts = broadcasts
.filter((broadcast) => !existingIds.has(broadcast.id))
.map((broadcast) => ({
...broadcast,
receivedAt,
}))
if (nextBroadcasts.length === 0) {
return state
}
return {
jackpotBroadcasts: [
...nextBroadcasts,
...state.jackpotBroadcasts,
].slice(0, MAX_JACKPOT_BROADCAST_COUNT),
}
})
},
requestRealtimeConnection: () => {
set({ shouldConnectRealtime: true })
},
@@ -138,7 +184,7 @@ export const selectVisibleAnnouncements = (state: GameSessionStoreState) =>
export type GameSessionStore = typeof useGameSessionStore
export type GameSessionStoreData = Pick<
GameSessionStoreState,
'announcements' | 'connection' | 'dashboard'
'announcements' | 'connection' | 'dashboard' | 'jackpotBroadcasts'
>
export type { AnnouncementState, ConnectionState, DashboardState }

View File

@@ -1,2 +1,3 @@
export * from './game-auto-hosting-store'
export * from './game-round-store'
export * from './game-session-store'