import { ClientMessageType, ServerMessageType, createAckMessage, createHeartbeatAckMessage, createLeaveMessage, createSyncPracticeInfoMessage, decodeServerMessage, getServerMessageTypeName, } from "@/utils/matchProtocol"; import { MESSAGETYPESV2 } from "@/constants"; import { getDirectionText } from "@/util"; import { normalizeId, normalizeMatchInfo, normalizePlainObject, pickField, } from "@/utils/matchAdapter"; // 比赛服 websocket 独立管理器: // 负责连接、解码、ACK/LEAVE,并把比赛服消息适配成项目现有 socket-inbox 业务事件。 let socket = null; let currentContext = null; 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; // 比赛服消息类型先映射成项目里已有的 V2 业务消息,页面仍然复用原来的 socket-inbox 流程。 const BUSINESS_TYPE_BY_SERVER_TYPE = { [ServerMessageType.SERVER_MSG_MATCH_READY]: MESSAGETYPESV2.AboutToStart, [ServerMessageType.SERVER_MSG_MATCH_START]: MESSAGETYPESV2.BattleStart, [ServerMessageType.SERVER_MSG_NOW_YOU]: MESSAGETYPESV2.ToSomeoneShoot, [ServerMessageType.SERVER_MSG_SHOT]: MESSAGETYPESV2.ShootResult, [ServerMessageType.SERVER_MSG_NEW_ROUND]: MESSAGETYPESV2.NewRound, [ServerMessageType.SERVER_MSG_MATCH_END]: MESSAGETYPESV2.BattleEnd, [ServerMessageType.SERVER_MSG_CHECK]: MESSAGETYPESV2.TestDistance, [ServerMessageType.SERVER_MSG_NOT_ENOUGH_DISTANCE]: MESSAGETYPESV2.InvalidShot, [ServerMessageType.SERVER_MSG_PRACTICE_END]: MESSAGETYPESV2.BattleEnd, }; 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"; export const MATCH_WS_PRACTICE_SYNC_EVENT = "match-ws-practice-sync"; function normalizeShootData(shootData) { if (!shootData || typeof shootData !== "object") return shootData; const normalized = { ...shootData }; if (normalized.distance === undefined && normalized.dst !== undefined) { normalized.distance = normalized.dst; } return normalized; } // 兼容普通接口通知的 camelCase 和 protobuf 解码后的 snake_case。 function normalizePracticeInfo(practiceInfo = {}) { if (!practiceInfo || typeof practiceInfo !== "object") return {}; const normalized = normalizePlainObject(practiceInfo); if (practiceInfo.shoot_data || normalized.shootData) { normalized.shootData = normalizeShootData( normalizePlainObject(practiceInfo.shoot_data || normalized.shootData) ); } if (Array.isArray(practiceInfo.details || normalized.details)) { normalized.details = (practiceInfo.details || normalized.details) .map(normalizePlainObject) .map(normalizeShootData); } return normalized; } function getCurrentMode(message = {}, normalizedInfo = {}) { const mode = Number( pickField(message, "mode") ?? normalizedInfo.mode ?? currentContext?.mode ); return Number.isFinite(mode) ? mode : undefined; } function isMeleeMessage(message = {}, normalizedInfo = {}) { const mode = getCurrentMode(message, normalizedInfo); if (mode !== undefined) return mode > 3; if (currentContext?.isMelee !== undefined) return currentContext.isMelee; return normalizeRoute(getCurrentPageInfo()?.route) === "pages/melee-battle"; } function isHalfRestTimeout(message = {}, matchInfo = {}) { if (message.type !== ServerMessageType.SERVER_MSG_TIMEOUT) return false; if (!isMeleeMessage(message, matchInfo)) return false; const status = Number(pickField(message, "status") ?? matchInfo.status); return status === MATCH_STATUS_HALF_REST || !Number.isFinite(status); } function getBusinessType(message, matchInfo) { if (isHalfRestTimeout(message, matchInfo)) return MESSAGETYPESV2.HalfRest; if (message.type === ServerMessageType.SERVER_MSG_TIMEOUT) { return MESSAGETYPESV2.BattleEnd; } return BUSINESS_TYPE_BY_SERVER_TYPE[message.type]; } function buildBusinessMessage(message) { const matchInfo = normalizeMatchInfo(message.match_info); if ( message.type === ServerMessageType.SERVER_MSG_MATCH_READY && (matchInfo.status === undefined || matchInfo.status === null || matchInfo.status === "") ) { matchInfo.status = 0; } const practiceInfo = normalizePracticeInfo(message.practice_info); const businessType = getBusinessType(message, matchInfo); if (!businessType) return null; const mode = getCurrentMode(message, matchInfo); const isMelee = isMeleeMessage(message, matchInfo); const isSecondHalfStart = message.type === ServerMessageType.SERVER_MSG_MATCH_START && isMelee && currentContext?.meleeHalfRest === true; if (currentContext) { if (mode !== undefined) { currentContext.mode = mode; currentContext.isMelee = isMelee; } else if (isMelee) { currentContext.isMelee = true; } if (businessType === MESSAGETYPESV2.HalfRest) { currentContext.meleeHalfRest = true; } else if (message.type === ServerMessageType.SERVER_MSG_MATCH_START) { currentContext.meleeHalfRest = false; } } const matchId = normalizeId( pickField(message, "matchId", "match_id") || matchInfo.matchId || practiceInfo.id || currentContext?.matchId ); const shootData = normalizeShootData( matchInfo.shootData || practiceInfo.shootData || (message.shoot_data ? normalizePlainObject(message.shoot_data) : undefined) ); const details = Array.isArray(practiceInfo.details) ? practiceInfo.details : matchInfo.details; return { ...matchInfo, ...practiceInfo, type: businessType, id: matchId, matchId, shootData, details, sequence: message.sequence, timestamp: message.timestamp, matchWsType: message.type, matchWsTypeName: getServerMessageTypeName(message.type), isSecondHalfStart, }; } function buildPracticeSyncMessage(message) { const practiceInfo = normalizePracticeInfo(message.practice_info); const matchId = normalizeId( pickField(message, "matchId", "match_id") || practiceInfo.id || currentContext?.matchId ); return { matchId, timestamp: message.timestamp, sequence: message.sequence, practiceInfo, }; } function getReadySnapshotKey(matchId) { return `${MATCH_READY_SNAPSHOT_PREFIX}${normalizeId(matchId)}`; } function persistReadyTeams(message) { const teams = message?.teams || {}; if (Number(message?.mode) > 3) return; const bluePlayers = teams[1]?.players || teams["1"]?.players; const redPlayers = teams[2]?.players || teams["2"]?.players; if (Array.isArray(bluePlayers)) uni.setStorageSync("blue-team", bluePlayers); if (Array.isArray(redPlayers)) uni.setStorageSync("red-team", redPlayers); } function persistReadySnapshot(message) { const matchId = normalizeId(message?.matchId || message?.id); if (!matchId) return; uni.setStorageSync(getReadySnapshotKey(matchId), { ...message, savedAt: Date.now(), }); } function getBattlePageUrl(message) { const matchId = normalizeId(message?.matchId || message?.id); const mode = Number(message?.mode); if (!matchId) return ""; if (!Number.isFinite(mode)) return ""; return mode <= 3 ? `/pages/team-battle/index?battleId=${matchId}` : `/pages/melee-battle?battleId=${matchId}`; } function getCurrentPageInfo() { if (typeof getCurrentPages !== "function") return null; const pages = getCurrentPages(); return pages[pages.length - 1] || null; } function normalizeRoute(route) { return String(route || "").replace(/^\//, ""); } function getRouteFromUrl(url) { return normalizeRoute(String(url || "").split("?")[0]); } function isCurrentBattlePage(url, matchId) { const page = getCurrentPageInfo(); const route = normalizeRoute(page?.route); const targetRoute = getRouteFromUrl(url); if (!targetRoute || route !== targetRoute) return false; const options = page?.options || page?.$page?.options || {}; const expectedMatchId = normalizeId(matchId); if (!expectedMatchId) return true; const routeMatchId = normalizeId(options.battleId); if (routeMatchId) return routeMatchId === expectedMatchId; return Boolean( options.fromReturn && normalizeId(currentContext?.matchId) === expectedMatchId ); } function scheduleReadyRouteFallback(message) { const url = getBattlePageUrl(message); const matchId = normalizeId(message?.matchId || message?.id); const routeKey = `${matchId}:${url}`; if (!url || lastReadyRouteKey === routeKey) return; lastReadyRouteKey = routeKey; setTimeout(() => { if (isCurrentBattlePage(url, matchId)) return; const currentRoute = normalizeRoute(getCurrentPageInfo()?.route); // battle-room 和 match-page 本身会消费 AboutToStart;这里不绕过页面内状态,避免好友房误触退房逻辑。 if (currentRoute === "pages/battle-room" || currentRoute === "pages/match-page") return; uni.redirectTo({ url, fail: (err) => { console.log("[match-ws] ready route fallback failed", err, { url }); }, }); }, READY_ROUTE_FALLBACK_DELAY_MS); } function emitBusinessMessage(message) { if (!message) return; if (message.type === MESSAGETYPESV2.AboutToStart) { persistReadyTeams(message); persistReadySnapshot(message); scheduleReadyRouteFallback(message); } uni.$emit("socket-inbox", message); } function resolvePlayerTeam(message, playerId) { const teams = message?.teams || {}; const redPlayers = teams[2]?.players || teams["2"]?.players || []; return redPlayers.some((item) => String(item.id) === String(playerId)) ? "red" : "blue"; } function getNowYouAudioKeys(message) { const current = message?.current || {}; const keys = []; if (current.goldRound) { keys.push("决金箭轮"); } else if (current.round) { const roundIndex = Math.max(0, Number(current.round) - 1); const roundName = ROUND_AUDIO_NAMES[roundIndex] || current.round; keys.push(`第${roundName}轮`); } if (String(current.playerId) === String(currentContext?.userId)) { keys.push("轮到你了"); return keys; } const team = resolvePlayerTeam(message, current.playerId); keys.push(team === "red" ? "请红方射箭" : "请蓝方射箭"); return keys; } function getShootResultAudioKeys(shootData) { if (!shootData) return []; const keys = [ shootData.ring ? `${shootData.ringX ? "X" : shootData.ring}环` : "未上靶", ]; if (shootData.angle !== null && shootData.angle !== undefined) { keys.push(`向${getDirectionText(shootData.angle)}调整`); } if (shootData.threeConsecutive10Rings === true) { keys.push("tententen"); } return keys; } function getTestDistanceAudioKeys(shootData) { const distance = Number(shootData?.distance ?? shootData?.dst); if (Number.isNaN(distance)) return []; if (distance === 0) return ["未发现靶纸,请瞄准靶纸射箭"]; return [distance / 100 >= 5 ? "\u8ddd\u79bb\u5408\u683c" : "\u8ddd\u79bb\u4e0d\u8db3"]; } function getAckAudioKeys(message, businessMessage) { switch (message.type) { case ServerMessageType.SERVER_MSG_MATCH_START: return [businessMessage?.isSecondHalfStart ? "下半场开始" : "比赛开始"]; case ServerMessageType.SERVER_MSG_NOW_YOU: if (isMeleeMessage(message, businessMessage)) return []; return getNowYouAudioKeys(businessMessage); case ServerMessageType.SERVER_MSG_SHOT: if ( isMeleeMessage(message, businessMessage) && String(businessMessage?.shootData?.playerId) !== String(currentContext?.userId) ) { return []; } return getShootResultAudioKeys(businessMessage?.shootData); case ServerMessageType.SERVER_MSG_MATCH_END: return ["比赛结束"]; case ServerMessageType.SERVER_MSG_TIMEOUT: if (businessMessage?.type === MESSAGETYPESV2.HalfRest) return ["中场休息"]; return ["比赛结束"]; case ServerMessageType.SERVER_MSG_PRACTICE_END: return [currentContext?.practiceEndAudioKey || "比赛结束"]; case ServerMessageType.SERVER_MSG_CHECK: return getTestDistanceAudioKeys(businessMessage?.shootData); case ServerMessageType.SERVER_MSG_NOT_ENOUGH_DISTANCE: return ["射击无效"]; default: return []; } } function shouldCloseAfterAck(message, businessMessage) { if ( message.type === ServerMessageType.SERVER_MSG_TIMEOUT && businessMessage?.type === MESSAGETYPESV2.HalfRest ) { return false; } return ( message.type === ServerMessageType.SERVER_MSG_MATCH_END || message.type === ServerMessageType.SERVER_MSG_TIMEOUT || message.type === ServerMessageType.SERVER_MSG_PRACTICE_END ); } // 追加 token 查询参数;如果后端地址已经带 token,则保持原样。 function appendQuery(url, key, value) { if (!value || new RegExp(`[?&]${key}=`).test(url)) return url; return `${url}${url.includes("?") ? "&" : "?"}${key}=${encodeURIComponent( value )}`; } // 后端可能下发完整 ws 地址,也可能只给 host:port,这里统一转成可连接地址。 function normalizeServerUrl(serverAddr, token) { if (!serverAddr || typeof serverAddr !== "string") return ""; let url = serverAddr.trim(); if (!url) return ""; if (!/^wss?:\/\//i.test(url)) { const [address, query = ""] = url.split("?"); const hasPath = address.includes("/"); const host = hasPath ? address : `${address}${address.includes(":") ? "" : ":8011"}/ws`; url = `ws://${host}${query ? `?${query}` : ""}`; } return appendQuery(url, "token", token); } function getServerEndpoint(url) { return String(url || "").split("?")[0]; } function getUrlToken(url) { const match = String(url || "").match(/[?&]token=([^&#]+)/i); if (!match?.[1]) return ""; try { return decodeURIComponent(match[1]); } catch (_) { return match[1]; } } 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; const currentSocket = socket; currentSocket.send({ data: buffer, success: () => { console.log("发送比赛服 WebSocket 消息", label, clientMessage); }, fail: (err) => { if (socket !== currentSocket) return; console.log("比赛服 WebSocket 消息发送失败", label, clientMessage, err); handleUnexpectedDisconnect(currentSocket, "send-failed", err); }, }); } function sendHeartbeatAck() { // 心跳 ACK 不进入 pendingAcks,收到后立即回复。 const clientMessage = { type: ClientMessageType.CLIENT_MSG_HEARTBEAT_ACK, }; sendBuffer( createHeartbeatAckMessage(), "CLIENT_MSG_HEARTBEAT_ACK", clientMessage ); } function sendPracticeInfoSync() { if (!socket || !currentContext?.matchId || !currentContext?.userId) return; const clientMessage = { type: ClientMessageType.CLIENT_MSG_SYNC_PRACTICE_INFO, match_id: currentContext.matchId, user_id: currentContext.userId, }; sendBuffer( createSyncPracticeInfoMessage({ matchId: clientMessage.match_id, userId: clientMessage.user_id, }), "CLIENT_MSG_SYNC_PRACTICE_INFO", clientMessage ); } function sendAck({ matchId, sequence }) { // sequence 由后端处理,前端只原样带回,不做重排和断线补发。 if (sequence === undefined || sequence === null || sequence === "") return; const clientMessage = { type: ClientMessageType.CLIENT_MSG_ACK, match_id: matchId, sequence, }; sendBuffer( createAckMessage({ matchId, sequence }), "CLIENT_MSG_ACK", clientMessage ); } function completeAckTask(task) { if (task?.timer) clearTimeout(task.timer); sendAck(task); if (task.leaveAfterAck) { setTimeout(() => { closeMatchWebSocket({ reason: "match-end" }); }, 0); } } function isSameAckTask(task, payload = {}) { if (String(task?.sequence) !== String(payload.sequence)) return false; if (!payload.matchId) return true; return String(task?.matchId) === String(payload.matchId); } function completePendingAckByMessage(payload = {}) { if (payload.sequence === undefined || payload.sequence === null || payload.sequence === "") { return; } const index = pendingAcks.findIndex((task) => isSameAckTask(task, payload)); if (index === -1) return; const [task] = pendingAcks.splice(index, 1); console.log("[match-ws] ack after page audio", task.sequence, payload); completeAckTask(task); } function flushPendingAcks() { // 每次语音播报完成,只确认队首那条等待相同语音 key 的服务端消息。 if (!pendingAcks.length) return; const endedKey = arguments[0]; const task = pendingAcks[0]; if (task.expectedAudioKey && endedKey && task.expectedAudioKey !== endedKey) { return; } completeAckTask(pendingAcks.shift()); } function ensureAudioAckListener() { // 复用现有全局 audioEnded 事件,确保 ACK 时机落在播报结束之后。 if (audioAckListenerReady) return; uni.$on("audioEnded", flushPendingAcks); uni.$on(MATCH_WS_AUDIO_ACK_EVENT, completePendingAckByMessage); audioAckListenerReady = true; } function removeAudioAckListener() { if (!audioAckListenerReady) return; uni.$off("audioEnded", flushPendingAcks); uni.$off(MATCH_WS_AUDIO_ACK_EVENT, completePendingAckByMessage); audioAckListenerReady = false; } function queueAckAfterAudio(message, businessMessage) { // 终止消息即使没有 sequence,也要等结束语音完成后主动关闭连接。 const leaveAfterAck = shouldCloseAfterAck(message, businessMessage); const hasSequence = message.sequence !== undefined && message.sequence !== null && message.sequence !== ""; if (!hasSequence && !leaveAfterAck) return; const task = { matchId: normalizeId( pickField(message, "matchId", "match_id") || currentContext?.matchId ), sequence: message.sequence, leaveAfterAck, }; const actionLabel = hasSequence ? "ack" : "terminal close"; const audioKeys = getAckAudioKeys(message, businessMessage).filter(Boolean); if (!audioKeys.length) { console.log( `[match-ws] ${actionLabel} immediately without audio`, getServerMessageTypeName(message.type), message.sequence ); completeAckTask(task); return; } task.expectedAudioKey = audioKeys[audioKeys.length - 1]; task.timer = setTimeout(() => { const index = pendingAcks.indexOf(task); if (index === -1) return; pendingAcks.splice(index, 1); console.log( `[match-ws] ${actionLabel} audio wait timeout`, getServerMessageTypeName(message.type), message.sequence, task.expectedAudioKey ); completeAckTask(task); }, ACK_AUDIO_TIMEOUT_MS); pendingAcks.push(task); console.log( `[match-ws] ${actionLabel} queued until audioEnded`, getServerMessageTypeName(message.type), message.sequence, task.expectedAudioKey ); } function handleMessage(data) { // 比赛服下发 raw protobuf frame,先解码,再根据消息类型决定是否 ACK。 let message; try { message = decodeServerMessage(data); } catch (err) { console.log("[match-ws] decode failed", err, data); return; } if (message.type === ServerMessageType.SERVER_MSG_HEARTBEAT) { sendHeartbeatAck(); return; } const typeName = getServerMessageTypeName(message.type); console.log("收到比赛服 WebSocket 消息", typeName, message); const decodedMatchId = normalizeId(pickField(message, "matchId", "match_id")); if (message.type === ServerMessageType.SERVER_MSG_SYNC_PRACTICE_INFO) { // 同步响应是完整快照,不映射成开始/报靶等实时事件,避免重放页面副作用。 queueAckAfterAudio(message, null); if ( decodedMatchId && currentContext?.matchId && decodedMatchId !== currentContext.matchId ) { console.log("[match-ws] ignore mismatched practice sync", { expectedMatchId: currentContext.matchId, receivedMatchId: decodedMatchId, }); return; } const syncMessage = buildPracticeSyncMessage(message); if (syncMessage.matchId && currentContext) { currentContext.matchId = syncMessage.matchId; } uni.$emit(MATCH_WS_PRACTICE_SYNC_EVENT, syncMessage); return; } if (decodedMatchId && currentContext) { currentContext.matchId = decodedMatchId; } const businessMessage = buildBusinessMessage(message); if (businessMessage?.matchId && currentContext) { currentContext.matchId = businessMessage.matchId; } queueAckAfterAudio(message, businessMessage); emitBusinessMessage(businessMessage); } function sendLeave() { // 主动关闭、比赛结束、页面离开时发 CLIENT_MSG_LEAVE。 if (!socket || !currentContext?.matchId) return; const clientMessage = { type: ClientMessageType.CLIENT_MSG_LEAVE, match_id: currentContext.matchId, user_id: currentContext.userId, }; sendBuffer( createLeaveMessage({ matchId: clientMessage.match_id, userId: clientMessage.user_id, }), "CLIENT_MSG_LEAVE", clientMessage ); } export function connectMatchWebSocket(options = {}) { // 入口参数来自原 websocket 的比赛服地址通知,不影响原有 websocket 连接。 const { serverAddr, matchId, userId, token, mode, requestPracticeInfoOnOpen = false, appHideResumable = false, practiceEndAudioKey = "", force = false, reconnecting = false, reconnectReason = "", } = options; const normalizedMatchId = normalizeId(matchId); const normalizedUserId = normalizeId(userId); const normalizedMode = Number(mode); const incomingUrl = normalizeServerUrl(serverAddr, token); const currentUrlToken = getUrlToken(currentContext?.url); const shouldKeepAuthenticatedUrl = !!( currentContext && currentContext.matchId === normalizedMatchId && getServerEndpoint(currentContext.url) === getServerEndpoint(incomingUrl) && currentUrlToken && !getUrlToken(incomingUrl) ); const url = shouldKeepAuthenticatedUrl ? currentContext.url : incomingUrl; const resolvedServerAddr = shouldKeepAuthenticatedUrl ? currentContext.serverAddr : serverAddr; const resolvedToken = shouldKeepAuthenticatedUrl ? currentContext.token || currentUrlToken : token; if (shouldKeepAuthenticatedUrl) { console.log("[match-ws] keep authenticated url for same match", { matchId: normalizedMatchId, endpoint: getServerEndpoint(url), }); } if (!url || !normalizedMatchId) { console.log("[match-ws] missing serverAddr or matchId", options); return; } const isSameContext = currentContext?.url === url && currentContext?.matchId === normalizedMatchId; if (!force && socket && isSameContext) { // 同一场比赛同一地址重复通知时不重复建连。 console.log("[match-ws] already connected or connecting", { matchId: normalizedMatchId, url, }); return; } 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: resolvedServerAddr, matchId: normalizedMatchId, userId: normalizedUserId, token: resolvedToken, url, mode: Number.isFinite(normalizedMode) ? normalizedMode : undefined, isMelee: Number.isFinite(normalizedMode) ? normalizedMode > 3 : undefined, requestPracticeInfoOnOpen: requestPracticeInfoOnOpen === true, appHideResumable: appHideResumable === true, practiceEndAudioKey: String(practiceEndAudioKey || "").trim(), meleeHalfRest: isSameContext ? currentContext?.meleeHalfRest === true : false, connectedOnce, }; ensureAudioAckListener(); emitMatchSocketState(reconnecting ? "reconnecting" : "connecting", { reason: reconnectReason, attempt: reconnectAttempt, }); const socketTask = uni.connectSocket({ url, success: () => { console.log("[match-ws] connect requested", { matchId: normalizedMatchId, url, }); }, fail: (err) => { if (socket !== socketTask) return; 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, }); if (currentContext?.requestPracticeInfoOnOpen) { sendPracticeInfoSync(); } }); 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; console.log("[match-ws] closed", { manualClose, result, context: currentContext, }); if (manualClose) return; handleUnexpectedDisconnect(socketTask, "socket-close", result, { closeTask: false, }); }); } export function connectMatchWebSocketFromNotice(notice, fallbackUserId) { // 原 websocket 下发的比赛服通知统一从这里转成连接参数。 if (!notice) return; connectMatchWebSocket({ serverAddr: pickField(notice, "serverAddr", "server_addr"), matchId: pickField(notice, "matchId", "match_id"), userId: pickField(notice, "userId", "user_id") || fallbackUserId, mode: pickField(notice, "mode"), token: notice.token || notice.wsToken || notice.ws_token, }); } export function setMatchAppHideResumable(enabled) { if (!currentContext) return; currentContext.appHideResumable = enabled === true; } export function closeMatchWebSocket(options = {}) { // 默认关闭时会发送 LEAVE;内部切换连接可通过 sendLeave:false 跳过。 const { reason = "manual" } = options; // 可恢复训练切后台只断开传输层,不能把它上报成主动离场。 const shouldSendLeave = options.sendLeave === undefined ? !( reason === "app-hide" && currentContext?.appHideResumable === true ) : options.sendLeave === true; manualClose = true; clearReconnectTimer(); clearSocketHealthTimers(); reconnectAttempt = 0; pendingAcks.forEach((task) => { if (task?.timer) clearTimeout(task.timer); }); pendingAcks.length = 0; if (socket) { const currentSocket = socket; if (shouldSendLeave) sendLeave(); socket = null; try { currentSocket.close({ reason }); } catch (err) { console.log("[match-ws] close failed", err); } } isConnecting = false; removeAudioAckListener(); console.log("[match-ws] close requested", { reason, currentContext }); currentContext = null; } export default { ClientMessageType, ServerMessageType, connectMatchWebSocket, connectMatchWebSocketFromNotice, setMatchAppHideResumable, closeMatchWebSocket, forceReconnectMatchWebSocket, handleMatchNetworkStatusChange, };