From 98ca638b6337cf23a5f44ce25b5d4505ddbfe25d Mon Sep 17 00:00:00 2001 From: zhangyibo95 <690096405@qq.com> Date: Fri, 10 Jul 2026 15:11:45 +0800 Subject: [PATCH] =?UTF-8?q?update:=E6=96=B0=E5=A2=9E=E6=AF=94=E8=B5=9B?= =?UTF-8?q?=E6=9C=8D=E6=96=AD=E7=BA=BF=E9=87=8D=E8=BF=9E=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.vue | 18 +++ src/matchWebsocket.js | 244 ++++++++++++++++++++++++++++++-- src/pages/melee-battle.vue | 100 +++++++++---- src/pages/team-battle/index.vue | 15 ++ 4 files changed, 339 insertions(+), 38 deletions(-) diff --git a/src/App.vue b/src/App.vue index 7264f01..601fa03 100644 --- a/src/App.vue +++ b/src/App.vue @@ -80,6 +80,10 @@ // audioManager.play("射箭声音") } + function onNetworkStatusChange(status) { + matchWebsocket.handleMatchNetworkStatusChange(status); + } + function connectMatchServerFromMessage(content) { const messages = Array.isArray(content) ? content : [content]; messages.forEach((message) => { @@ -108,6 +112,17 @@ uni.$on("update-online", emitUpdateOnline); uni.$on("session-kicked-out", onSessionKickedOut); uni.$on("device-bind-invalid", onDeviceBindInvalid); + if (typeof uni.offNetworkStatusChange === "function") { + uni.offNetworkStatusChange(onNetworkStatusChange); + } + if (typeof uni.onNetworkStatusChange === "function") { + uni.onNetworkStatusChange(onNetworkStatusChange); + } + if (typeof uni.getNetworkType === "function") { + uni.getNetworkType({ + success: onNetworkStatusChange + }); + } const token = uni.getStorageSync( `${uni.getAccountInfoSync().miniProgram.envVersion}_token` ); @@ -122,6 +137,9 @@ uni.$off("update-online", emitUpdateOnline); uni.$off("session-kicked-out", onSessionKickedOut); uni.$off("device-bind-invalid", onDeviceBindInvalid); + if (typeof uni.offNetworkStatusChange === "function") { + uni.offNetworkStatusChange(onNetworkStatusChange); + } matchWebsocket.closeMatchWebSocket({ reason: "app-hide" }); diff --git a/src/matchWebsocket.js b/src/matchWebsocket.js index 9bc3016..3f73dd3 100644 --- a/src/matchWebsocket.js +++ b/src/matchWebsocket.js @@ -24,11 +24,20 @@ let isConnecting = false; let manualClose = false; let audioAckListenerReady = false; let lastReadyRouteKey = ""; +let reconnectTimer = null; +let connectTimeoutTimer = null; +let livenessTimer = null; +let reconnectAttempt = 0; +let networkConnected = true; +let lastNetworkType = ""; // 后端要求非心跳消息必须等前端语音播报完成后再 ACK。 const pendingAcks = []; const ACK_AUDIO_TIMEOUT_MS = 9000; const READY_ROUTE_FALLBACK_DELAY_MS = 300; +const CONNECT_TIMEOUT_MS = 8000; +const LIVENESS_TIMEOUT_MS = 30000; +const RECONNECT_DELAYS_MS = [1000, 2000, 4000, 8000, 15000]; const ROUND_AUDIO_NAMES = ["一", "二", "三", "四", "五"]; const MATCH_STATUS_HALF_REST = 3; @@ -47,6 +56,7 @@ const BUSINESS_TYPE_BY_SERVER_TYPE = { const MATCH_READY_SNAPSHOT_PREFIX = "match-ready-snapshot:"; export const MATCH_WS_AUDIO_ACK_EVENT = "match-ws-audio-ack"; +export const MATCH_WS_STATE_EVENT = "match-ws-state"; function normalizeShootData(shootData) { if (!shootData || typeof shootData !== "object") return shootData; @@ -382,6 +392,166 @@ function normalizeServerUrl(serverAddr, token) { return appendQuery(url, "token", token); } +function emitMatchSocketState(state, extra = {}) { + uni.$emit(MATCH_WS_STATE_EVENT, { + state, + matchId: currentContext?.matchId || "", + ...extra, + }); +} + +function clearReconnectTimer() { + if (!reconnectTimer) return; + clearTimeout(reconnectTimer); + reconnectTimer = null; +} + +function clearConnectTimeout() { + if (!connectTimeoutTimer) return; + clearTimeout(connectTimeoutTimer); + connectTimeoutTimer = null; +} + +function clearLivenessTimer() { + if (!livenessTimer) return; + clearTimeout(livenessTimer); + livenessTimer = null; +} + +function clearSocketHealthTimers() { + clearConnectTimeout(); + clearLivenessTimer(); +} + +function closeSocketTask(socketTask, reason) { + if (!socketTask) return; + try { + socketTask.close({ reason }); + } catch (err) { + console.log("[match-ws] close socket task failed", reason, err); + } +} + +function detachCurrentSocket(reason) { + const currentSocket = socket; + socket = null; + isConnecting = false; + clearSocketHealthTimers(); + closeSocketTask(currentSocket, reason); +} + +function scheduleReconnect(reason, { immediate = false } = {}) { + if ( + manualClose || + !currentContext || + !networkConnected || + reconnectTimer || + socket || + isConnecting + ) { + return; + } + + const delay = immediate + ? 0 + : RECONNECT_DELAYS_MS[ + Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1) + ]; + const attempt = reconnectAttempt + 1; + reconnectAttempt = attempt; + emitMatchSocketState("reconnecting", { reason, delay, attempt }); + + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + if (manualClose || !currentContext || !networkConnected) return; + connectMatchWebSocket({ + ...currentContext, + force: true, + reconnecting: true, + reconnectReason: reason, + }); + }, delay); +} + +function handleUnexpectedDisconnect( + socketTask, + reason, + detail, + { closeTask = true } = {} +) { + if (socket !== socketTask) return; + + socket = null; + isConnecting = false; + clearSocketHealthTimers(); + if (closeTask) closeSocketTask(socketTask, reason); + + console.log("[match-ws] unexpected disconnect", { + reason, + detail, + context: currentContext, + }); + emitMatchSocketState(networkConnected ? "disconnected" : "offline", { + reason, + }); + scheduleReconnect(reason); +} + +function startConnectTimeout(socketTask) { + clearConnectTimeout(); + connectTimeoutTimer = setTimeout(() => { + handleUnexpectedDisconnect(socketTask, "connect-timeout"); + }, CONNECT_TIMEOUT_MS); +} + +function resetLivenessTimer(socketTask) { + clearLivenessTimer(); + livenessTimer = setTimeout(() => { + handleUnexpectedDisconnect(socketTask, "heartbeat-timeout"); + }, LIVENESS_TIMEOUT_MS); +} + +export function forceReconnectMatchWebSocket(reason = "force-reconnect") { + if (manualClose || !currentContext) return false; + + clearReconnectTimer(); + detachCurrentSocket(reason); + if (!networkConnected) { + emitMatchSocketState("offline", { reason }); + return false; + } + + reconnectAttempt = 0; + scheduleReconnect(reason, { immediate: true }); + return true; +} + +export function handleMatchNetworkStatusChange(status = {}) { + const previousConnected = networkConnected; + const networkType = String(status.networkType || ""); + const nextConnected = + status.isConnected !== false && networkType.toLowerCase() !== "none"; + const networkTypeChanged = + !!lastNetworkType && !!networkType && lastNetworkType !== networkType; + + networkConnected = nextConnected; + if (networkType) lastNetworkType = networkType; + if (manualClose || !currentContext) return; + + if (!nextConnected) { + clearReconnectTimer(); + detachCurrentSocket("network-offline"); + emitMatchSocketState("offline", { reason: "network-offline" }); + return; + } + + if (!previousConnected || networkTypeChanged) { + forceReconnectMatchWebSocket( + networkTypeChanged ? "network-type-changed" : "network-recovered" + ); + } +} + // 统一发送二进制 protobuf 消息,并在日志里保留发送类型。 function sendBuffer(buffer, label, clientMessage) { if (!socket || !buffer) return; @@ -395,6 +565,7 @@ function sendBuffer(buffer, label, clientMessage) { fail: (err) => { if (socket !== currentSocket) return; console.log("比赛服 WebSocket 消息发送失败", label, clientMessage, err); + handleUnexpectedDisconnect(currentSocket, "send-failed", err); }, }); } @@ -582,7 +753,16 @@ function sendLeave() { export function connectMatchWebSocket(options = {}) { // 入口参数来自原 websocket 的比赛服地址通知,不影响原有 websocket 连接。 - const { serverAddr, matchId, userId, token, mode } = options; + const { + serverAddr, + matchId, + userId, + token, + mode, + force = false, + reconnecting = false, + reconnectReason = "", + } = options; const url = normalizeServerUrl(serverAddr, token); const normalizedMatchId = normalizeId(matchId); const normalizedUserId = normalizeId(userId); @@ -593,11 +773,10 @@ export function connectMatchWebSocket(options = {}) { return; } - if ( - socket && + const isSameContext = currentContext?.url === url && - currentContext?.matchId === normalizedMatchId - ) { + currentContext?.matchId === normalizedMatchId; + if (!force && socket && isSameContext) { // 同一场比赛同一地址重复通知时不重复建连。 console.log("[match-ws] already connected or connecting", { matchId: normalizedMatchId, @@ -606,22 +785,38 @@ export function connectMatchWebSocket(options = {}) { return; } - // 切换比赛服地址时先关闭旧连接,不发送 LEAVE,避免误通知旧比赛离场。 - closeMatchWebSocket({ sendLeave: false, reason: "switch" }); + if (force && isSameContext) { + // 网络切换或假死重连时只销毁旧链路,保留比赛上下文且不发送 LEAVE。 + detachCurrentSocket(reconnectReason || "force-reconnect"); + } else if (!isSameContext) { + // 切换比赛服地址时先关闭旧连接,不发送 LEAVE,避免误通知旧比赛离场。 + closeMatchWebSocket({ sendLeave: false, reason: "switch" }); + } + clearReconnectTimer(); manualClose = false; isConnecting = true; + if (!isSameContext) reconnectAttempt = 0; + const connectedOnce = isSameContext && currentContext?.connectedOnce === true; currentContext = { serverAddr, matchId: normalizedMatchId, userId: normalizedUserId, + token, url, mode: Number.isFinite(normalizedMode) ? normalizedMode : undefined, isMelee: Number.isFinite(normalizedMode) ? normalizedMode > 3 : undefined, - meleeHalfRest: false, + meleeHalfRest: isSameContext + ? currentContext?.meleeHalfRest === true + : false, + connectedOnce, }; ensureAudioAckListener(); + emitMatchSocketState(reconnecting ? "reconnecting" : "connecting", { + reason: reconnectReason, + attempt: reconnectAttempt, + }); const socketTask = uni.connectSocket({ url, @@ -636,40 +831,60 @@ export function connectMatchWebSocket(options = {}) { socket = null; isConnecting = false; console.log("[match-ws] connect failed", err); + clearSocketHealthTimers(); + emitMatchSocketState(networkConnected ? "disconnected" : "offline", { + reason: "connect-failed", + }); + scheduleReconnect("connect-failed"); }, }); socket = socketTask; + startConnectTimeout(socketTask); socketTask.onOpen(() => { if (socket !== socketTask) return; + clearConnectTimeout(); isConnecting = false; + const wasReconnected = + currentContext?.connectedOnce === true || reconnecting || reconnectAttempt > 0; + if (currentContext) currentContext.connectedOnce = true; + reconnectAttempt = 0; + resetLivenessTimer(socketTask); console.log("[match-ws] connected", { matchId: normalizedMatchId, url, + reconnected: wasReconnected, + }); + emitMatchSocketState("open", { + reason: reconnectReason, + reconnected: wasReconnected, }); }); socketTask.onMessage((res) => { if (socket !== socketTask) return; + resetLivenessTimer(socketTask); handleMessage(res.data); }); socketTask.onError((err) => { if (socket !== socketTask) return; console.log("[match-ws] socket error", err); + handleUnexpectedDisconnect(socketTask, "socket-error", err); }); socketTask.onClose((result) => { if (socket !== socketTask) return; - socket = null; - isConnecting = false; console.log("[match-ws] closed", { manualClose, result, context: currentContext, }); - if (!manualClose) removeAudioAckListener(); + if (manualClose) return; + handleUnexpectedDisconnect(socketTask, "socket-close", result, { + closeTask: false, + }); }); } @@ -690,6 +905,9 @@ export function closeMatchWebSocket(options = {}) { const { sendLeave: shouldSendLeave = true, reason = "manual" } = options; manualClose = true; + clearReconnectTimer(); + clearSocketHealthTimers(); + reconnectAttempt = 0; pendingAcks.forEach((task) => { if (task?.timer) clearTimeout(task.timer); }); @@ -700,7 +918,7 @@ export function closeMatchWebSocket(options = {}) { if (shouldSendLeave) sendLeave(); socket = null; try { - currentSocket.close(); + currentSocket.close({ reason }); } catch (err) { console.log("[match-ws] close failed", err); } @@ -718,4 +936,6 @@ export default { connectMatchWebSocket, connectMatchWebSocketFromNotice, closeMatchWebSocket, + forceReconnectMatchWebSocket, + handleMatchNetworkStatusChange, }; diff --git a/src/pages/melee-battle.vue b/src/pages/melee-battle.vue index 665fa7e..2869326 100644 --- a/src/pages/melee-battle.vue +++ b/src/pages/melee-battle.vue @@ -17,6 +17,7 @@ import { closeMatchWebSocket, connectMatchWebSocket, MATCH_WS_AUDIO_ACK_EVENT, + MATCH_WS_STATE_EVENT, } from "@/matchWebsocket"; import { MESSAGETYPESV2 } from "@/constants"; import { takeMatchReturnSnapshot } from "@/utils/matchReturn"; @@ -55,6 +56,7 @@ const showOfflineModal = ref(false); const xRingStreaks = ref({}); let battleEnded = false; let skipNextRestoreOnShow = false; +let restoreGeneration = 0; function clearHalfRestCountdown() { if (halfRestTimer) { @@ -78,13 +80,24 @@ function getHalfRestSeconds(battleInfo) { } } - const endTime = Number(battleInfo?.halfRestEndTime ?? battleInfo?.restEndTime); - if (!Number.isFinite(endTime) || endTime <= 0) return HALF_REST_SECONDS; + const timeoutTime = normalizeTimestamp(battleInfo?.timeoutTime); + if (timeoutTime) { + const elapsedSeconds = Math.max(0, (Date.now() - timeoutTime) / 1000); + return Math.max( + 0, + Math.min(HALF_REST_SECONDS, Math.ceil(HALF_REST_SECONDS - elapsedSeconds)) + ); + } - const timestamp = endTime < 1e12 ? endTime * 1000 : endTime; - const diffSeconds = (timestamp - Date.now()) / 1000; - if (diffSeconds > 0 && diffSeconds <= HALF_REST_SECONDS) { - return Math.ceil(diffSeconds); + const endTime = normalizeTimestamp( + battleInfo?.halfRestEndTime ?? battleInfo?.restEndTime + ); + if (endTime) { + const diffSeconds = (endTime - Date.now()) / 1000; + return Math.max( + 0, + Math.min(HALF_REST_SECONDS, Math.ceil(diffSeconds)) + ); } return HALF_REST_SECONDS; @@ -92,7 +105,14 @@ function getHalfRestSeconds(battleInfo) { function startHalfRestCountdown(seconds = HALF_REST_SECONDS) { clearHalfRestCountdown(); - halfRestRemain.value = Math.max(0, Math.ceil(Number(seconds) || HALF_REST_SECONDS)); + const secondsValue = Number(seconds); + const normalizedSeconds = Number.isFinite(secondsValue) + ? secondsValue + : HALF_REST_SECONDS; + halfRestRemain.value = Math.max( + 0, + Math.min(HALF_REST_SECONDS, Math.ceil(normalizedSeconds)) + ); if (halfRestRemain.value <= 0) return; @@ -305,6 +325,47 @@ function recoverData(battleInfo, { force = false } = {}) { } } +async function restoreLatestBattle() { + if (!battleId.value) return; + + const currentRestoreId = ++restoreGeneration; + let result = null; + try { + result = normalizeBattleInfo(await getBattleAPI(battleId.value)); + } catch (err) { + console.log("restore latest melee battle failed:", err); + return; + } + if (currentRestoreId !== restoreGeneration || !result) return; + + if (result.status === 2) { + battleEnded = true; + uni.showToast({ + title: "比赛已结束", + icon: "none", + }); + uni.navigateBack({ + delta: 2, + }); + return; + } + + reconnectMatchServer(result); + recoverData(result, { force: true }); +} + +function onMatchSocketState(event) { + if (event?.state !== "open" || event?.reconnected !== true) return; + if ( + event.matchId && + battleId.value && + String(event.matchId) !== String(battleId.value) + ) { + return; + } + restoreLatestBattle(); +} + onLoad(async (options) => { const returnSnapshot = options.fromReturn ? takeMatchReturnSnapshot() : null; skipNextRestoreOnShow = false; @@ -392,7 +453,7 @@ async function onReceiveMessage(msg) { halfTimeTip.value = true; halfRest.value = true; tips.value = "准备下半场"; - startHalfRestCountdown(); + startHalfRestCountdown(getHalfRestSeconds(msg)); } else if (msg.type === MESSAGETYPESV2.BattleEnd) { battleEnded = true; notifyMatchAudioAck(msg); @@ -409,14 +470,17 @@ onMounted(async () => { keepScreenOn: true, }); uni.$on("socket-inbox", onReceiveMessage); + uni.$on(MATCH_WS_STATE_EVENT, onMatchSocketState); await laserCloseAPI(); }); onBeforeUnmount(() => { uni.setKeepScreenOn({ keepScreenOn: false, }); + restoreGeneration += 1; clearHalfRestCountdown(); uni.$off("socket-inbox", onReceiveMessage); + uni.$off(MATCH_WS_STATE_EVENT, onMatchSocketState); closeBattleServer("melee-battle-unmount"); audioManager.stopAll(); }); @@ -425,28 +489,12 @@ onHide(() => { closeBattleServer("melee-battle-hide"); }); -onShow(async () => { +onShow(() => { if (skipNextRestoreOnShow) { skipNextRestoreOnShow = false; return; } - if (battleId.value) { - const result = normalizeBattleInfo(await getBattleAPI(battleId.value)); - if (!result) return; - if (result.status === 2) { - battleEnded = true; - uni.showToast({ - title: "比赛已结束", - icon: "none", - }); - uni.navigateBack({ - delta: 2, - }); - } else { - reconnectMatchServer(result); - recoverData(result, { force: true }); - } - } + restoreLatestBattle(); }); diff --git a/src/pages/team-battle/index.vue b/src/pages/team-battle/index.vue index 9c0a9e0..d8d370a 100644 --- a/src/pages/team-battle/index.vue +++ b/src/pages/team-battle/index.vue @@ -17,6 +17,7 @@ import { closeMatchWebSocket, connectMatchWebSocket, MATCH_WS_AUDIO_ACK_EVENT, + MATCH_WS_STATE_EVENT, } from "@/matchWebsocket"; import { MESSAGETYPESV2 } from "@/constants"; import { getDirectionText } from "@/util"; @@ -1227,6 +1228,18 @@ function scheduleRestoreLatestBattle() { }, RESTORE_EMPTY_ID_RETRY_DELAY); } +function onMatchSocketState(event) { + if (event?.state !== "open" || event?.reconnected !== true) return; + if ( + event.matchId && + battleId.value && + String(event.matchId) !== String(battleId.value) + ) { + return; + } + scheduleRestoreLatestBattle(); +} + // 页面统一的 socket 回调,只负责把消息送进战况队列。 function onReceiveMessage(message) { enqueueBattleMessage(message); @@ -1302,6 +1315,7 @@ onMounted(async () => { uni.$on(AUDIO_INTERRUPTION_END_EVENT, handleBattleRecovered); uni.$on(PROGRESS_ZERO_EVENT, onProgressZero); uni.$on(COUNTDOWN_READY_EVENT, hideRestoreLoading); + uni.$on(MATCH_WS_STATE_EVENT, onMatchSocketState); await laserCloseAPI(); }); @@ -1314,6 +1328,7 @@ onBeforeUnmount(() => { uni.$off("audioEnded", onAudioEnded); uni.$off(PROGRESS_ZERO_EVENT, onProgressZero); uni.$off(COUNTDOWN_READY_EVENT, hideRestoreLoading); + uni.$off(MATCH_WS_STATE_EVENT, onMatchSocketState); if (pendingRestoreTimer) { clearTimeout(pendingRestoreTimer); pendingRestoreTimer = null;