Android build v1.1.2
This commit is contained in:
@@ -0,0 +1,221 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
publishPvpMatchState,
|
||||
requestPvpRematch,
|
||||
type PvpMatchSnapshot,
|
||||
type PvpMatchSide,
|
||||
type PvpMatchStatus,
|
||||
type PvpRematchResponse,
|
||||
} from './pvpRoguelike'
|
||||
|
||||
export type LiveMatchProgressPayload<TSideState> = {
|
||||
state: TSideState
|
||||
status: PvpMatchStatus
|
||||
stage: number
|
||||
encounterIndex: number
|
||||
encountersCleared: number
|
||||
enemyHealth: number
|
||||
alive: boolean
|
||||
elapsedTicks: number
|
||||
}
|
||||
|
||||
type StartLiveMatchSyncOptions<TSideState> = {
|
||||
matchId: string
|
||||
intervalMs?: number
|
||||
getPayload: () => LiveMatchProgressPayload<TSideState>
|
||||
onSnapshot: (snapshot: PvpMatchSnapshot<TSideState>) => void
|
||||
onError?: (reason: unknown) => void
|
||||
}
|
||||
|
||||
type RequestLiveRematchOptions<TSideState> = {
|
||||
matchId: string
|
||||
maxPendingAttempts?: number
|
||||
maxFailureAttempts?: number
|
||||
pendingDelayMs?: number
|
||||
failureDelayMs?: number
|
||||
onMatched: (result: Required<Pick<PvpRematchResponse<TSideState>, 'match' | 'side'>>) => void
|
||||
onExpired: () => void
|
||||
onFailure: (reason: unknown) => void
|
||||
}
|
||||
|
||||
type LivePvpMatchBase = {
|
||||
id: string
|
||||
opponentName: string
|
||||
}
|
||||
|
||||
type UsePvpLiveMatchSyncOptions<TSideState, TLiveMatch extends LivePvpMatchBase> = {
|
||||
liveMatch: TLiveMatch | null
|
||||
syncEnabled: boolean
|
||||
getPayload: (liveMatch: TLiveMatch) => LiveMatchProgressPayload<TSideState>
|
||||
onSnapshot: (snapshot: PvpMatchSnapshot<TSideState>, liveMatch: TLiveMatch) => void
|
||||
onError?: (reason: unknown) => void
|
||||
startLiveMatch: (match: PvpMatchSnapshot<TSideState>, side: PvpMatchSide, message?: string) => void
|
||||
}
|
||||
|
||||
type RematchState = {
|
||||
matchId: string | null
|
||||
requested: boolean
|
||||
message: string
|
||||
}
|
||||
|
||||
/**
|
||||
* React wrapper for live PvP state sync and rematch UX. Mode screens provide
|
||||
* payload and snapshot rules; shared polling and rematch state live here.
|
||||
*/
|
||||
export function usePvpLiveMatchSync<TSideState, TLiveMatch extends LivePvpMatchBase>({
|
||||
liveMatch,
|
||||
syncEnabled,
|
||||
getPayload,
|
||||
onSnapshot,
|
||||
onError,
|
||||
startLiveMatch,
|
||||
}: UsePvpLiveMatchSyncOptions<TSideState, TLiveMatch>) {
|
||||
const [rematchState, setRematchState] = useState<RematchState>({
|
||||
matchId: null,
|
||||
requested: false,
|
||||
message: '',
|
||||
})
|
||||
const getPayloadRef = useRef(getPayload)
|
||||
const onSnapshotRef = useRef(onSnapshot)
|
||||
const onErrorRef = useRef(onError)
|
||||
const startLiveMatchRef = useRef(startLiveMatch)
|
||||
const liveMatchId = liveMatch?.id
|
||||
|
||||
useEffect(() => {
|
||||
getPayloadRef.current = getPayload
|
||||
onSnapshotRef.current = onSnapshot
|
||||
onErrorRef.current = onError
|
||||
startLiveMatchRef.current = startLiveMatch
|
||||
}, [getPayload, onError, onSnapshot, startLiveMatch])
|
||||
|
||||
useEffect(() => {
|
||||
if (!liveMatch || !syncEnabled) return undefined
|
||||
return startLiveMatchSync<TSideState>({
|
||||
matchId: liveMatch.id,
|
||||
getPayload: () => getPayloadRef.current(liveMatch),
|
||||
onSnapshot: (snapshot) => onSnapshotRef.current(snapshot, liveMatch),
|
||||
onError: (reason) => onErrorRef.current?.(reason),
|
||||
})
|
||||
}, [liveMatch, syncEnabled])
|
||||
|
||||
const handleRematch = useCallback(() => {
|
||||
if (!liveMatch || (rematchState.matchId === liveMatch.id && rematchState.requested)) return undefined
|
||||
const { id, opponentName } = liveMatch
|
||||
setRematchState({
|
||||
matchId: id,
|
||||
requested: true,
|
||||
message: `Waiting for ${opponentName} to rematch...`,
|
||||
})
|
||||
return requestLiveRematch<TSideState>({
|
||||
matchId: id,
|
||||
onMatched: (result) => {
|
||||
startLiveMatchRef.current(result.match, result.side, `Rematch against ${opponentName} begins.`)
|
||||
},
|
||||
onExpired: () => {
|
||||
setRematchState({ matchId: id, requested: false, message: 'Rematch expired.' })
|
||||
},
|
||||
onFailure: (reason) => {
|
||||
setRematchState({
|
||||
matchId: id,
|
||||
requested: false,
|
||||
message: reason instanceof Error ? reason.message : 'Unable to request rematch.',
|
||||
})
|
||||
},
|
||||
})
|
||||
}, [liveMatch, rematchState.matchId, rematchState.requested])
|
||||
|
||||
const currentRematchState = rematchState.matchId === liveMatchId
|
||||
? rematchState
|
||||
: { matchId: liveMatchId ?? null, requested: false, message: '' }
|
||||
|
||||
return {
|
||||
rematchRequested: currentRematchState.requested,
|
||||
rematchMessage: currentRematchState.message,
|
||||
handleRematch,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls the backend rematch endpoint until both players accept, the request
|
||||
* expires, or repeated network failures make the rematch unavailable.
|
||||
*/
|
||||
export function requestLiveRematch<TSideState>({
|
||||
matchId,
|
||||
maxPendingAttempts = 180,
|
||||
maxFailureAttempts = 10,
|
||||
pendingDelayMs = 700,
|
||||
failureDelayMs = 900,
|
||||
onMatched,
|
||||
onExpired,
|
||||
onFailure,
|
||||
}: RequestLiveRematchOptions<TSideState>) {
|
||||
let cancelled = false
|
||||
let attempts = 0
|
||||
|
||||
const schedule = (callback: () => void, delay: number) => {
|
||||
window.setTimeout(callback, delay)
|
||||
}
|
||||
|
||||
const handleResponse = (result: PvpRematchResponse<TSideState>) => {
|
||||
if (cancelled) return
|
||||
if (result.status === 'matched' && result.match && result.side) {
|
||||
onMatched({ match: result.match, side: result.side })
|
||||
return
|
||||
}
|
||||
attempts += 1
|
||||
if (attempts >= maxPendingAttempts) {
|
||||
onExpired()
|
||||
return
|
||||
}
|
||||
schedule(pollRematch, pendingDelayMs)
|
||||
}
|
||||
|
||||
const pollRematch = () => {
|
||||
requestPvpRematch<TSideState>(matchId)
|
||||
.then(handleResponse)
|
||||
.catch((reason: unknown) => {
|
||||
if (cancelled) return
|
||||
attempts += 1
|
||||
if (attempts >= maxFailureAttempts) {
|
||||
onFailure(reason)
|
||||
return
|
||||
}
|
||||
schedule(pollRematch, failureDelayMs)
|
||||
})
|
||||
}
|
||||
|
||||
pollRematch()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes local live-match progress and polls opponent state on a fixed
|
||||
* interval. Mode-specific snapshot handling stays in the caller.
|
||||
*/
|
||||
export function startLiveMatchSync<TSideState>({
|
||||
matchId,
|
||||
intervalMs = 700,
|
||||
getPayload,
|
||||
onSnapshot,
|
||||
onError = () => undefined,
|
||||
}: StartLiveMatchSyncOptions<TSideState>) {
|
||||
let stopped = false
|
||||
const syncMatch = () => {
|
||||
publishPvpMatchState<TSideState>(matchId, getPayload())
|
||||
.then((snapshot) => {
|
||||
if (!stopped) onSnapshot(snapshot)
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (!stopped) onError(reason)
|
||||
})
|
||||
}
|
||||
|
||||
syncMatch()
|
||||
const timer = window.setInterval(syncMatch, intervalMs)
|
||||
return () => {
|
||||
stopped = true
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user