From af07d09ab6d12bd136361e502ed29da234039d55 Mon Sep 17 00:00:00 2001 From: zhangyibo95 <690096405@qq.com> Date: Wed, 8 Jul 2026 18:18:27 +0800 Subject: [PATCH] =?UTF-8?q?update:=E4=BC=98=E5=8C=96=E6=AF=94=E8=B5=9B?= =?UTF-8?q?=E9=98=9F=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis.js | 5 +- src/matchWebsocket.js | 285 ++++++++++++++++++++++++++++---- src/pages/melee-battle.vue | 95 ++++++++++- src/pages/team-battle/index.vue | 121 +++++++++++++- src/utils/matchAdapter.js | 121 ++++++++++++++ 5 files changed, 589 insertions(+), 38 deletions(-) create mode 100644 src/utils/matchAdapter.js diff --git a/src/apis.js b/src/apis.js index 3689e62..c885963 100644 --- a/src/apis.js +++ b/src/apis.js @@ -1,3 +1,5 @@ +import { normalizeBattleApiResult } from "@/utils/matchAdapter"; + let BASE_URL = "https://api.shelingxingqiu.com/api/shoot"; // 默认正式版 try { @@ -553,9 +555,10 @@ export const getReadyAPI = (roomId) => { }; export const getBattleAPI = async (battleId) => { - return request("POST", "/user/match/info", { + const result = await request("POST", "/user/match/info", { id: battleId, }); + return normalizeBattleApiResult(result); }; export const kickPlayerAPI = (number, userId) => { diff --git a/src/matchWebsocket.js b/src/matchWebsocket.js index 4902ce6..8ef3b39 100644 --- a/src/matchWebsocket.js +++ b/src/matchWebsocket.js @@ -7,42 +7,220 @@ import { decodeServerMessage, getServerMessageTypeName, } from "@/utils/matchProtocol"; +import { MESSAGETYPESV2 } from "@/constants"; +import { getDirectionText } from "@/util"; +import { + normalizeId, + normalizeMatchInfo, + normalizePlainObject, + pickField, +} from "@/utils/matchAdapter"; // 比赛服 websocket 独立管理器: -// 当前阶段只负责连接、解码、打印、ACK/LEAVE,不接管任何页面 UI。 +// 负责连接、解码、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 = ["一", "二", "三", "四", "五"]; -// 当前仍是“只连接、只解码、只打印”阶段,还没有真正接管比赛语音播报。 -// 为了先验证协议闭环,这里临时把“打印完成”当作“播报完成”并立即 ACK。 -const AUTO_ACK_WITHOUT_AUDIO = true; - -// 这些比赛消息需要在 audioEnded 后补 ACK;心跳 ACK 单独即时处理。 -const ACK_REQUIRED_TYPES = new Set([ - ServerMessageType.SERVER_MSG_MATCH_READY, - ServerMessageType.SERVER_MSG_MATCH_START, - ServerMessageType.SERVER_MSG_NOW_YOU, - ServerMessageType.SERVER_MSG_SHOT, - ServerMessageType.SERVER_MSG_NEW_ROUND, - ServerMessageType.SERVER_MSG_MATCH_END, - ServerMessageType.SERVER_MSG_TIMEOUT, - ServerMessageType.SERVER_MSG_CHECK, -]); +// 比赛服消息类型先映射成项目里已有的 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 pickField(source, camelKey, snakeKey) { - return source?.[camelKey] ?? source?.[snakeKey]; +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 normalizeId(value) { - if (value === undefined || value === null || value === "") return ""; - return String(value); +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,则保持原样。 @@ -101,6 +279,7 @@ function sendAck({ matchId, sequence }) { } function completeAckTask(task) { + if (task?.timer) clearTimeout(task.timer); sendAck(task); if (task.leaveAfterAck) { setTimeout(() => { @@ -109,9 +288,33 @@ function completeAckTask(task) { } } +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()); } @@ -119,19 +322,20 @@ 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) { - // 非 ACK_REQUIRED_TYPES 的消息只打印,不自动确认,避免扩大发送面。 +function queueAckAfterAudio(message, businessMessage) { + // 除心跳外,只要服务端带了 sequence,都需要走 ACK;没有语音的消息立即 ACK。 if ( - !ACK_REQUIRED_TYPES.has(message.type) || message.sequence === undefined || message.sequence === null || message.sequence === "" @@ -146,9 +350,10 @@ function queueAckAfterAudio(message) { leaveAfterAck: message.type === ServerMessageType.SERVER_MSG_MATCH_END, }; - if (AUTO_ACK_WITHOUT_AUDIO) { + const audioKeys = getAckAudioKeys(message, businessMessage).filter(Boolean); + if (!audioKeys.length) { console.log( - "[match-ws] ack immediately in print-only mode", + "[match-ws] ack immediately without audio", getServerMessageTypeName(message.type), message.sequence ); @@ -156,11 +361,25 @@ function queueAckAfterAudio(message) { 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 + message.sequence, + task.expectedAudioKey ); } @@ -187,7 +406,12 @@ function handleMessage(data) { return; } - queueAckAfterAudio(message); + const businessMessage = buildBusinessMessage(message); + if (businessMessage?.matchId && currentContext) { + currentContext.matchId = businessMessage.matchId; + } + queueAckAfterAudio(message, businessMessage); + emitBusinessMessage(businessMessage); } function sendLeave() { @@ -306,6 +530,9 @@ export function closeMatchWebSocket(options = {}) { const { sendLeave: shouldSendLeave = true, reason = "manual" } = options; manualClose = true; + pendingAcks.forEach((task) => { + if (task?.timer) clearTimeout(task.timer); + }); pendingAcks.length = 0; if (socket) { diff --git a/src/pages/melee-battle.vue b/src/pages/melee-battle.vue index 2510d0f..cb7ab04 100644 --- a/src/pages/melee-battle.vue +++ b/src/pages/melee-battle.vue @@ -13,6 +13,7 @@ import TestDistance from "@/components/TestDistance.vue"; import SModal from "@/components/SModal.vue"; import audioManager from "@/audioManager"; import { getBattleAPI, laserCloseAPI } from "@/apis"; +import { connectMatchWebSocket } from "@/matchWebsocket"; import { MESSAGETYPESV2 } from "@/constants"; import useStore from "@/store"; import { storeToRefs } from "pinia"; @@ -31,6 +32,14 @@ const playersScores = ref([]); const halfTimeTip = ref(false); const halfRest = ref(false); const HALF_REST_SECONDS = 20; +const MATCH_READY_SNAPSHOT_PREFIX = "match-ready-snapshot:"; +const MATCH_STATUS_BY_TEXT = { + MATCH_STATUS_READY: 0, + MATCH_STATUS_STARTED: 1, + MATCH_STATUS_END: 2, + MATCH_STATUS_TIMEOUT: 3, + MATCH_STATUS_UNEXPECTEDLY: 4, +}; const halfRestRemain = ref(HALF_REST_SECONDS); let halfRestTimer = null; /** 控制设备离线提示弹窗的显示状态 */ @@ -93,6 +102,68 @@ const currentPlayer = computed(() => ); const isCurrentUserSvip = computed(() => currentPlayer.value?.sVip === true); +function getReadySnapshotKey(matchId) { + return `${MATCH_READY_SNAPSHOT_PREFIX}${String(matchId || "")}`; +} + +function normalizeBattleInfo(battleInfo) { + if (!battleInfo || typeof battleInfo !== "object") return battleInfo; + + const statusText = battleInfo.statusText || battleInfo.status_text; + if ( + (battleInfo.status === undefined || + battleInfo.status === null || + battleInfo.status === "") && + MATCH_STATUS_BY_TEXT[statusText] !== undefined + ) { + return { + ...battleInfo, + status: MATCH_STATUS_BY_TEXT[statusText], + }; + } + + return battleInfo; +} + +function hasKnownStatus(battleInfo) { + return !( + battleInfo?.status === undefined || + battleInfo?.status === null || + battleInfo?.status === "" + ); +} + +function hasStartedSnapshot(battleInfo) { + if (hasKnownStatus(battleInfo)) return Number(battleInfo.status) !== 0; + + const current = battleInfo?.current || {}; + return !!( + current.playerId || + current.startTime || + current.round || + (Array.isArray(battleInfo?.rounds) && battleInfo.rounds.length) + ); +} + +function takeReadySnapshot(matchId) { + const key = getReadySnapshotKey(matchId); + const snapshot = uni.getStorageSync(key); + if (snapshot) uni.removeStorageSync(key); + return normalizeBattleInfo(snapshot); +} + +function reconnectMatchServer(battleInfo) { + const status = Number(battleInfo?.status); + if ([2, 3, 4].includes(status)) return; + if (!battleInfo?.serverAddr) return; + + connectMatchWebSocket({ + serverAddr: battleInfo.serverAddr, + matchId: battleInfo.matchId || battleId.value, + userId: user.value.id, + }); +} + /** * 监听设备在线状态,大乱斗比赛进行中设备离线时弹窗提示用户 */ @@ -103,6 +174,7 @@ watch(online, (newVal, oldVal) => { }); function recoverData(battleInfo, { force = false } = {}) { + battleInfo = normalizeBattleInfo(battleInfo); if (!battleInfo) return; try { if (battleInfo.way === 1) title.value = "好友约战 - 大乱斗"; @@ -121,7 +193,14 @@ function recoverData(battleInfo, { force = false } = {}) { players.value = []; } - start.value = battleInfo.status !== 0; + if (hasKnownStatus(battleInfo)) { + start.value = battleInfo.status !== 0; + } else if (!hasStartedSnapshot(battleInfo)) { + if (start.value !== true) start.value = false; + return; + } else { + start.value = true; + } if (battleInfo.status === 0) { const readyRemain = (Date.now() - (battleInfo.createTime || Date.now())) / 1000; @@ -131,9 +210,10 @@ function recoverData(battleInfo, { force = false } = {}) { return; } + const rounds = Array.isArray(battleInfo.rounds) ? battleInfo.rounds : []; tips.value = - (battleInfo.rounds.length !== 2 ? "上" : "下") + "半场:请先射6箭"; - playersScores.value = battleInfo.rounds.map((r) => ({ ...r.shoots })); + (rounds.length !== 2 ? "上" : "下") + "半场:请先射6箭"; + playersScores.value = rounds.map((r) => ({ ...r.shoots })); const totals = {}; players.value.forEach((p) => { const total = playersScores.value.reduce((acc, round) => { @@ -171,6 +251,8 @@ function recoverData(battleInfo, { force = false } = {}) { onLoad(async (options) => { if (options.battleId) battleId.value = options.battleId; + const readySnapshot = takeReadySnapshot(battleId.value); + if (readySnapshot?.status === 0) recoverData(readySnapshot); // uni.enableAlertBeforeUnload({ // message: "离开比赛可能导致比赛失败,是否继续?", // success: (res) => { @@ -207,7 +289,9 @@ function checkAndPlayTententen(playerId, isTenPlusRingShot) { async function onReceiveMessage(msg) { if (Array.isArray(msg)) return; - if (msg.type === MESSAGETYPESV2.BattleStart) { + if (msg.type === MESSAGETYPESV2.AboutToStart) { + recoverData(msg); + } else if (msg.type === MESSAGETYPESV2.BattleStart) { clearHalfRestCountdown(); halfTimeTip.value = false; halfRest.value = false; @@ -267,7 +351,7 @@ onBeforeUnmount(() => { onShow(async () => { if (battleId.value) { - const result = await getBattleAPI(battleId.value); + const result = normalizeBattleInfo(await getBattleAPI(battleId.value)); if (!result) return; if (result.status === 2) { uni.showToast({ @@ -278,6 +362,7 @@ onShow(async () => { delta: 2, }); } else { + reconnectMatchServer(result); recoverData(result, { force: true }); } } diff --git a/src/pages/team-battle/index.vue b/src/pages/team-battle/index.vue index 79b7dd4..e7734e6 100644 --- a/src/pages/team-battle/index.vue +++ b/src/pages/team-battle/index.vue @@ -13,6 +13,7 @@ import TeamAvatars from "./components/TeamAvatars.vue"; import ShootProgress2 from "./components/ShootProgress2.vue"; import SModal from "./components/SModal.vue"; import { laserCloseAPI, getBattleAPI } from "@/apis"; +import { connectMatchWebSocket, MATCH_WS_AUDIO_ACK_EVENT } from "@/matchWebsocket"; import { MESSAGETYPESV2 } from "@/constants"; import { getDirectionText } from "@/util"; import audioManager, { @@ -42,6 +43,14 @@ const AUDIO_TIMEOUT_MAX = 12000; const BATTLE_CANCEL_RETURN_DELAY = 2000; const ROUND_AUDIO_NAMES = ["一", "二", "三", "四", "五"]; const X_RING_STREAKS_KEY = "team-battle-x-ring-streaks"; +const MATCH_READY_SNAPSHOT_PREFIX = "match-ready-snapshot:"; +const MATCH_STATUS_BY_TEXT = { + MATCH_STATUS_READY: 0, + MATCH_STATUS_STARTED: 1, + MATCH_STATUS_END: 2, + MATCH_STATUS_TIMEOUT: 3, + MATCH_STATUS_UNEXPECTEDLY: 4, +}; const PROGRESS_ZERO_EVENT = "team-battle-progress-zero"; const COUNTDOWN_READY_EVENT = "team-battle-countdown-ready"; @@ -129,6 +138,68 @@ function normalizeTimestamp(value) { return numberValue < 1000000000000 ? numberValue * 1000 : numberValue; } +function getReadySnapshotKey(matchId) { + return `${MATCH_READY_SNAPSHOT_PREFIX}${String(matchId || "")}`; +} + +function normalizeBattleInfo(battleInfo) { + if (!battleInfo || typeof battleInfo !== "object") return battleInfo; + + const statusText = battleInfo.statusText || battleInfo.status_text; + if ( + (battleInfo.status === undefined || + battleInfo.status === null || + battleInfo.status === "") && + MATCH_STATUS_BY_TEXT[statusText] !== undefined + ) { + return { + ...battleInfo, + status: MATCH_STATUS_BY_TEXT[statusText], + }; + } + + return battleInfo; +} + +function hasKnownStatus(battleInfo) { + return !( + battleInfo?.status === undefined || + battleInfo?.status === null || + battleInfo?.status === "" + ); +} + +function hasStartedSnapshot(battleInfo) { + if (hasKnownStatus(battleInfo)) return Number(battleInfo.status) !== 0; + + const current = battleInfo?.current || {}; + return !!( + current.playerId || + current.startTime || + current.round || + (Array.isArray(battleInfo?.rounds) && battleInfo.rounds.length) + ); +} + +function takeReadySnapshot(matchId) { + const key = getReadySnapshotKey(matchId); + const snapshot = uni.getStorageSync(key); + if (snapshot) uni.removeStorageSync(key); + return normalizeBattleInfo(snapshot); +} + +function reconnectMatchServer(battleInfo) { + const status = Number(battleInfo?.status); + if ([2, 3, 4].includes(status)) return; + if (!battleInfo?.serverAddr) return; + + connectMatchWebSocket({ + serverAddr: battleInfo.serverAddr, + matchId: battleInfo.matchId || battleId.value, + userId: user.value.id, + }); +} + // 从不同消息结构中提取服务端时间,作为恢复和去重的时间基准。 function getServerTime(message) { return normalizeTimestamp( @@ -289,6 +360,10 @@ function hideRestoreLoading() { } function showRestoreLoading() { + if (start.value === false) { + hideRestoreLoading(); + return; + } clearRestoreLoadingTimer(); restoreLoading.value = true; restoreLoadingTimer = setTimeout(() => { @@ -391,7 +466,9 @@ async function executeBattleTask(task, runId) { if (!task || !isQueueAlive(runId)) return; const type = task.type; - if (type === MESSAGETYPESV2.BattleStart) { + if (type === MESSAGETYPESV2.AboutToStart) { + applyBattleSnapshot(task.message, { restore: true }); + } else if (type === MESSAGETYPESV2.BattleStart) { await runBattleStartTask(task, runId); } else if (type === MESSAGETYPESV2.ToSomeoneShoot) { await runToSomeoneShootTask(task, runId); @@ -402,7 +479,7 @@ async function executeBattleTask(task, runId) { } else if (type === MESSAGETYPESV2.BattleEnd) { await runBattleEndTask(task, runId); } else if (type === MESSAGETYPESV2.InvalidShot) { - await runInvalidShotTask(runId); + await runInvalidShotTask(task, runId); } } @@ -448,6 +525,24 @@ function onAudioEnded(key) { }); } +function notifyMatchAudioAck(task) { + const message = task?.message; + if ( + message?.sequence === undefined || + message?.sequence === null || + message?.sequence === "" + ) { + return; + } + + uni.$emit(MATCH_WS_AUDIO_ACK_EVENT, { + matchId: message.matchId || battleId.value, + sequence: message.sequence, + matchWsType: message.matchWsType, + matchWsTypeName: message.matchWsTypeName, + }); +} + function handleBattleCovered() { if (pendingRestoreTimer) { clearTimeout(pendingRestoreTimer); @@ -526,6 +621,7 @@ function applyRestoreNewRoundSnapshot(battleInfo) { // 回填比赛基础信息:队伍、比分、轮次、金箭状态等公共字段都在这里统一处理。 function applyBattleBase(battleInfo) { + battleInfo = normalizeBattleInfo(battleInfo); if (!battleInfo) return; if (battleInfo.matchId) battleId.value = battleInfo.matchId; if (battleInfo.status !== undefined) { @@ -745,6 +841,8 @@ function applyReadyState(battleInfo) { // 快照恢复入口:只把页面拉到服务端最新状态,不重放已经发生过的语音。 function applyBattleSnapshot(battleInfo, { restore = false, restoreEventType = 0 } = {}) { + battleInfo = normalizeBattleInfo(battleInfo); + if (!battleInfo) return; // 快照恢复只负责“把页面拉回最新状态”,不重放历史语音。 applyBattleBase(battleInfo); if (battleInfo.status === 0) { @@ -752,6 +850,12 @@ function applyBattleSnapshot(battleInfo, { restore = false, restoreEventType = 0 return; } + if (!hasStartedSnapshot(battleInfo)) { + hideRestoreLoading(); + if (start.value === false) applyReadyState(battleInfo); + return; + } + start.value = true; showRoundTip.value = false; @@ -793,6 +897,7 @@ async function runBattleStartTask(task, runId) { pendingRoundAudio = true; updateShotInfo(task.message); await playAudioKeys("比赛开始", { interrupt: false }); + notifyMatchAudioAck(task); if (!isQueueAlive(runId)) return; } @@ -846,6 +951,7 @@ async function runToSomeoneShootTask(task, runId) { }); await audioPromise; + notifyMatchAudioAck(task); if (!isQueueAlive(runId)) return; const remainingSeconds = getRemainingSeconds(battleInfo, task, { @@ -918,6 +1024,7 @@ async function runShootResultTask(task) { const audioKeys = buildShootResultAudioKeys(battleInfo.shootData); if (isTententen) audioKeys.push("tententen"); await playAudioKeys(audioKeys, { interrupt: false }); + notifyMatchAudioAck(task); } // 新回合任务:展示上一回合结算弹窗,弹窗关闭后再允许下一轮继续。 @@ -977,6 +1084,7 @@ async function runBattleEndTask(task, runId) { // 终局语音必须完整播完,再决定跳转或返回。 await playAudioKeys("比赛结束", { interrupt: false, timeout: AUDIO_TIMEOUT_MAX }); + notifyMatchAudioAck(task); if (!isQueueAlive(runId)) return; if (matchStatus.value === 2) { @@ -991,13 +1099,14 @@ async function runBattleEndTask(task, runId) { } // 无效射击任务:弹 toast 并播报无效射击,不修改比赛主状态。 -async function runInvalidShotTask(runId) { +async function runInvalidShotTask(task, runId) { if (!isQueueAlive(runId)) return; uni.showToast({ title: "距离不足,无效", icon: "none", }); await playAudioKeys("射击无效", { interrupt: false }); + notifyMatchAudioAck(task); } // 回前台恢复入口:拉取服务端快照,处理结束态,然后继续消费增量队列。 @@ -1016,6 +1125,7 @@ async function restoreLatestBattle() { console.log("restore latest battle failed:", err); } if (currentRestoreId !== restoreGeneration) return; + result = normalizeBattleInfo(result); if (!result) { hideRestoreLoading(); runBattleQueue(); @@ -1041,6 +1151,7 @@ async function restoreLatestBattle() { if (result.status === 4) { clearXRingStreaks(); } + reconnectMatchServer(result); if (restoreEventType === MESSAGETYPESV2.NewRound) { const prevRound = getRestorePrevRound(result); @@ -1135,6 +1246,10 @@ onLoad((options) => { store.updateShotInfo(0, 0); store.updateTips(""); latestShotFlash.value = null; + const readySnapshot = takeReadySnapshot(battleId.value); + if (readySnapshot?.status === 0) { + applyBattleSnapshot(readySnapshot, { restore: true }); + } scheduleRestoreLatestBattle(); }); diff --git a/src/utils/matchAdapter.js b/src/utils/matchAdapter.js new file mode 100644 index 0000000..06fa42b --- /dev/null +++ b/src/utils/matchAdapter.js @@ -0,0 +1,121 @@ +// 比赛数据适配器:统一接口返回和比赛服 websocket 的字段结构。 +// 页面继续使用原来的 camelCase 数据,后端可以返回 snake_case 或新的 matchInfo 包装结构。 +export const MATCH_STATUS_BY_TEXT = { + MATCH_STATUS_READY: 0, + MATCH_STATUS_STARTED: 1, + MATCH_STATUS_END: 2, + MATCH_STATUS_TIMEOUT: 3, + MATCH_STATUS_UNEXPECTEDLY: 4, +}; + +export function pickField(source, camelKey, snakeKey) { + return source?.[camelKey] ?? source?.[snakeKey]; +} + +export function normalizeId(value) { + if (value === undefined || value === null || value === "") return ""; + return String(value); +} + +function toCamelKey(key) { + return key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); +} + +export function normalizePlainObject(value) { + if (Array.isArray(value)) return value.map(normalizePlainObject); + if (!value || typeof value !== "object") return value; + + return Object.keys(value).reduce((result, key) => { + result[toCamelKey(key)] = normalizePlainObject(value[key]); + return result; + }, {}); +} + +function normalizeMapValues(map = {}) { + return Object.keys(map || {}).reduce((result, key) => { + result[key] = normalizePlainObject(map[key]); + return result; + }, {}); +} + +function normalizeShootListMap(map = {}) { + return Object.keys(map || {}).reduce((result, key) => { + const value = map[key]; + const items = Array.isArray(value) + ? value + : Array.isArray(value?.items) + ? value.items + : []; + result[key] = items.map(normalizePlainObject); + return result; + }, {}); +} + +function normalizeRound(round = {}) { + const normalized = normalizePlainObject(round); + normalized.shoots = normalizeShootListMap(round.shoots || normalized.shoots); + normalized.scores = normalizeMapValues(round.scores || normalized.scores); + return normalized; +} + +export function normalizeMatchInfo(matchInfo = {}) { + if (!matchInfo || typeof matchInfo !== "object") return matchInfo; + + const normalized = normalizePlainObject(matchInfo); + const statusText = normalized.statusText || matchInfo.status_text; + + if ( + (normalized.status === undefined || + normalized.status === null || + normalized.status === "") && + MATCH_STATUS_BY_TEXT[statusText] !== undefined + ) { + normalized.status = MATCH_STATUS_BY_TEXT[statusText]; + } + + if (matchInfo.teams || normalized.teams) { + normalized.teams = normalizeMapValues(matchInfo.teams || normalized.teams); + } + if (Array.isArray(matchInfo.rounds || normalized.rounds)) { + normalized.rounds = (matchInfo.rounds || normalized.rounds).map(normalizeRound); + } + if (matchInfo.current || normalized.current) { + normalized.current = normalizePlainObject(matchInfo.current || normalized.current); + } + if (matchInfo.next || normalized.next) { + normalized.next = normalizePlainObject(matchInfo.next || normalized.next); + } + if (matchInfo.shoot_data || normalized.shootData) { + normalized.shootData = normalizePlainObject(matchInfo.shoot_data || normalized.shootData); + } + if (Array.isArray(matchInfo.result_list || normalized.resultList)) { + normalized.resultList = (matchInfo.result_list || normalized.resultList).map( + normalizePlainObject + ); + } + + return normalized; +} + +export function normalizeBattleApiResult(result) { + if (!result || typeof result !== "object") return result; + + // request 通常已经解出顶层 data;这里额外兼容未解包和旧版扁平结构。 + const resultData = result.data && typeof result.data === "object" ? result.data : null; + const payload = + resultData && (resultData.matchInfo || resultData.match_info) + ? resultData + : result; + const matchInfo = payload.matchInfo || payload.match_info || payload; + const normalized = normalizeMatchInfo(matchInfo); + const serverAddr = + payload.serverAddr || + payload.server_addr || + matchInfo.serverAddr || + matchInfo.server_addr || + normalized.serverAddr; + + if (serverAddr) normalized.serverAddr = serverAddr; + + return normalized; +}