import { ClientMessageType, ServerMessageType, createAckMessage, createHeartbeatAckMessage, createLeaveMessage, 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 = ""; // 后端要求非心跳消息必须等前端语音播报完成后再 ACK。 const pendingAcks = []; const ACK_AUDIO_TIMEOUT_MS = 9000; const READY_ROUTE_FALLBACK_DELAY_MS = 300; const ROUND_AUDIO_NAMES = ["一", "二", "三", "四", "五"]; // 比赛服消息类型先映射成项目里已有的 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_TIMEOUT]: MESSAGETYPESV2.BattleEnd, [ServerMessageType.SERVER_MSG_CHECK]: MESSAGETYPESV2.InvalidShot, [ServerMessageType.SERVER_MSG_NOT_ENOUGH_DISTANCE]: MESSAGETYPESV2.InvalidShot, }; const MATCH_READY_SNAPSHOT_PREFIX = "match-ready-snapshot:"; export const MATCH_WS_AUDIO_ACK_EVENT = "match-ws-audio-ack"; // 兼容普通接口通知的 camelCase 和 protobuf 解码后的 snake_case。 function buildBusinessMessage(message) { const businessType = BUSINESS_TYPE_BY_SERVER_TYPE[message.type]; if (!businessType) return null; const matchInfo = normalizeMatchInfo(message.match_info); const matchId = normalizeId( pickField(message, "matchId", "match_id") || matchInfo.matchId || currentContext?.matchId ); const shootData = matchInfo.shootData || (message.shoot_data ? normalizePlainObject(message.shoot_data) : undefined); return { ...matchInfo, type: businessType, id: matchId, matchId, shootData, sequence: message.sequence, timestamp: message.timestamp, matchWsType: message.type, matchWsTypeName: getServerMessageTypeName(message.type), }; } 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 isCurrentBattlePage(matchId) { const page = getCurrentPageInfo(); const route = normalizeRoute(page?.route); if (route !== "pages/team-battle/index" && route !== "pages/melee-battle") { return false; } const options = page?.options || page?.$page?.options || {}; return !matchId || normalizeId(options.battleId) === normalizeId(matchId); } 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(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); } console.log("[match-ws] emit socket-inbox", message.matchWsTypeName, 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)}调整`); } return keys; } function getAckAudioKeys(message, businessMessage) { switch (message.type) { case ServerMessageType.SERVER_MSG_MATCH_START: return ["比赛开始"]; case ServerMessageType.SERVER_MSG_NOW_YOU: return getNowYouAudioKeys(businessMessage); case ServerMessageType.SERVER_MSG_SHOT: return getShootResultAudioKeys(businessMessage?.shootData); case ServerMessageType.SERVER_MSG_MATCH_END: case ServerMessageType.SERVER_MSG_TIMEOUT: return ["比赛结束"]; case ServerMessageType.SERVER_MSG_CHECK: case ServerMessageType.SERVER_MSG_NOT_ENOUGH_DISTANCE: return ["射击无效"]; default: return []; } } // 追加 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); } // 统一发送二进制 protobuf 消息,并在日志里保留发送类型。 function sendBuffer(buffer, label) { if (!socket || !buffer) return; const currentSocket = socket; currentSocket.send({ data: buffer, success: () => { if (label === "heartbeat ack") return; console.log(`[match-ws] ${label} sent`, new Date()); }, fail: (err) => { if (socket !== currentSocket) return; console.log(`[match-ws] ${label} send failed`, err); }, }); } function sendHeartbeatAck() { // 心跳 ACK 不进入 pendingAcks,收到后立即回复。 sendBuffer(createHeartbeatAckMessage(), "heartbeat ack"); } function sendAck({ matchId, sequence }) { // sequence 由后端处理,前端只原样带回,不做重排和断线补发。 if (sequence === undefined || sequence === null || sequence === "") return; sendBuffer(createAckMessage({ matchId, sequence }), `ack ${sequence}`); } 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,都需要走 ACK;没有语音的消息立即 ACK。 if ( message.sequence === undefined || message.sequence === null || message.sequence === "" ) { return; } const task = { matchId: normalizeId( pickField(message, "matchId", "match_id") || currentContext?.matchId ), sequence: message.sequence, leaveAfterAck: message.type === ServerMessageType.SERVER_MSG_MATCH_END, }; const audioKeys = getAckAudioKeys(message, businessMessage).filter(Boolean); if (!audioKeys.length) { console.log( "[match-ws] ack 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] ack audio wait timeout", getServerMessageTypeName(message.type), message.sequence, task.expectedAudioKey ); completeAckTask(task); }, ACK_AUDIO_TIMEOUT_MS); pendingAcks.push(task); console.log( "[match-ws] ack 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; } const typeName = getServerMessageTypeName(message.type); console.log("[match-ws] message decoded", typeName, message); const decodedMatchId = normalizeId(pickField(message, "matchId", "match_id")); if (decodedMatchId && currentContext) { currentContext.matchId = decodedMatchId; } if (message.type === ServerMessageType.SERVER_MSG_HEARTBEAT) { sendHeartbeatAck(); return; } 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; sendBuffer( createLeaveMessage({ matchId: currentContext.matchId, userId: currentContext.userId, }), "leave" ); } export function connectMatchWebSocket(options = {}) { // 入口参数来自原 websocket 的比赛服地址通知,不影响原有 websocket 连接。 const { serverAddr, matchId, userId, token } = options; const url = normalizeServerUrl(serverAddr, token); const normalizedMatchId = normalizeId(matchId); const normalizedUserId = normalizeId(userId); if (!url || !normalizedMatchId) { console.log("[match-ws] missing serverAddr or matchId", options); return; } if ( socket && currentContext?.url === url && currentContext?.matchId === normalizedMatchId ) { // 同一场比赛同一地址重复通知时不重复建连。 console.log("[match-ws] already connected or connecting", { matchId: normalizedMatchId, url, }); return; } // 切换比赛服地址时先关闭旧连接,不发送 LEAVE,避免误通知旧比赛离场。 closeMatchWebSocket({ sendLeave: false, reason: "switch" }); manualClose = false; isConnecting = true; currentContext = { serverAddr, matchId: normalizedMatchId, userId: normalizedUserId, url, }; ensureAudioAckListener(); 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); }, }); socket = socketTask; socketTask.onOpen(() => { if (socket !== socketTask) return; isConnecting = false; console.log("[match-ws] connected", { matchId: normalizedMatchId, url, }); }); socketTask.onMessage((res) => { if (socket !== socketTask) return; handleMessage(res.data); }); socketTask.onError((err) => { if (socket !== socketTask) return; console.log("[match-ws] 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(); }); } 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, token: notice.token || notice.wsToken || notice.ws_token, }); } export function closeMatchWebSocket(options = {}) { // 默认关闭时会发送 LEAVE;内部切换连接可通过 sendLeave:false 跳过。 const { sendLeave: shouldSendLeave = true, reason = "manual" } = options; manualClose = true; 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(); } 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, closeMatchWebSocket, };