From b5373bee314474225e3046090f4ef6765d8d9689 Mon Sep 17 00:00:00 2001 From: zhangyibo95 <690096405@qq.com> Date: Fri, 10 Jul 2026 13:57:09 +0800 Subject: [PATCH] =?UTF-8?q?update:=E6=96=B0=E7=89=88=E6=9C=ACwebsocket?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis.js | 8 +-- src/components/ShootProgress.vue | 2 +- src/matchWebsocket.js | 101 ++++++++++++++++++++++++++++--- src/pages/match-page.vue | 13 ++++ src/pages/melee-battle.vue | 45 ++++++++++++-- src/pages/practise-one.vue | 18 ++++++ src/pages/practise-two.vue | 18 ++++++ src/pages/team-battle/index.vue | 35 ++++++++--- src/utils/match.min.js | 2 +- src/utils/matchProtocol.js | 1 + src/websocket.js | 4 +- 11 files changed, 216 insertions(+), 31 deletions(-) diff --git a/src/apis.js b/src/apis.js index e1357b6..9c47724 100644 --- a/src/apis.js +++ b/src/apis.js @@ -8,8 +8,8 @@ try { switch (envVersion) { case "develop": // 开发版 - BASE_URL = "http://192.168.1.2:8000/api/shoot"; - // BASE_URL = "https://apitest.shelingxingqiu.com/api/shoot"; + // BASE_URL = "http://192.168.1.2:8000/api/shoot"; + BASE_URL = "https://apitest.shelingxingqiu.com/api/shoot"; break; case "trial": // 体验版 BASE_URL = "https://apitest.shelingxingqiu.com/api/shoot"; @@ -277,8 +277,8 @@ export const startPractiseAPI = (id) => { return request("POST", "/user/practice/begin", { id }); }; -export const endPractiseAPI = () => { - return request("POST", "/user/practice/stop"); +export const endPractiseAPI = (id) => { + return request("POST", "/user/practice/stop", { id }); }; export const getPractiseAPI = async (id) => { diff --git a/src/components/ShootProgress.vue b/src/components/ShootProgress.vue index f5b24ba..f2448bd 100644 --- a/src/components/ShootProgress.vue +++ b/src/components/ShootProgress.vue @@ -150,7 +150,7 @@ async function onReceiveMessage(msg) { if (msg.details && Array.isArray(msg.details)) { arrow = msg.details[msg.details.length - 1]; } else { - if (msg.shootData.playerId !== user.value.id) return; + if (!msg.shootData || String(msg.shootData.playerId) !== String(user.value.id)) return; if (msg.shootData) arrow = msg.shootData; } let key = []; diff --git a/src/matchWebsocket.js b/src/matchWebsocket.js index df30ab3..9bc3016 100644 --- a/src/matchWebsocket.js +++ b/src/matchWebsocket.js @@ -30,6 +30,7 @@ const pendingAcks = []; const ACK_AUDIO_TIMEOUT_MS = 9000; const READY_ROUTE_FALLBACK_DELAY_MS = 300; const ROUND_AUDIO_NAMES = ["一", "二", "三", "四", "五"]; +const MATCH_STATUS_HALF_REST = 3; // 比赛服消息类型先映射成项目里已有的 V2 业务消息,页面仍然复用原来的 socket-inbox 流程。 const BUSINESS_TYPE_BY_SERVER_TYPE = { @@ -39,7 +40,6 @@ const BUSINESS_TYPE_BY_SERVER_TYPE = { [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.TestDistance, [ServerMessageType.SERVER_MSG_NOT_ENOUGH_DISTANCE]: MESSAGETYPESV2.InvalidShot, [ServerMessageType.SERVER_MSG_PRACTICE_END]: MESSAGETYPESV2.BattleEnd, @@ -76,12 +76,73 @@ function normalizePracticeInfo(practiceInfo = {}) { 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 businessType = BUSINESS_TYPE_BY_SERVER_TYPE[message.type]; + 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 matchInfo = normalizeMatchInfo(message.match_info); - const practiceInfo = normalizePracticeInfo(message.practice_info); + 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 || @@ -109,6 +170,7 @@ function buildBusinessMessage(message) { timestamp: message.timestamp, matchWsType: message.type, matchWsTypeName: getServerMessageTypeName(message.type), + isSecondHalfStart, }; } @@ -250,13 +312,23 @@ function getTestDistanceAudioKeys(shootData) { function getAckAudioKeys(message, businessMessage) { switch (message.type) { case ServerMessageType.SERVER_MSG_MATCH_START: - return ["比赛开始"]; + 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 ["比赛结束"]; case ServerMessageType.SERVER_MSG_CHECK: @@ -268,7 +340,14 @@ function getAckAudioKeys(message, businessMessage) { } } -function shouldCloseAfterAck(message) { +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 || @@ -416,7 +495,7 @@ function queueAckAfterAudio(message, businessMessage) { pickField(message, "matchId", "match_id") || currentContext?.matchId ), sequence: message.sequence, - leaveAfterAck: shouldCloseAfterAck(message), + leaveAfterAck: shouldCloseAfterAck(message, businessMessage), }; const audioKeys = getAckAudioKeys(message, businessMessage).filter(Boolean); @@ -503,10 +582,11 @@ function sendLeave() { export function connectMatchWebSocket(options = {}) { // 入口参数来自原 websocket 的比赛服地址通知,不影响原有 websocket 连接。 - const { serverAddr, matchId, userId, token } = options; + const { serverAddr, matchId, userId, token, mode } = options; const url = normalizeServerUrl(serverAddr, token); const normalizedMatchId = normalizeId(matchId); const normalizedUserId = normalizeId(userId); + const normalizedMode = Number(mode); if (!url || !normalizedMatchId) { console.log("[match-ws] missing serverAddr or matchId", options); @@ -536,6 +616,10 @@ export function connectMatchWebSocket(options = {}) { matchId: normalizedMatchId, userId: normalizedUserId, url, + mode: Number.isFinite(normalizedMode) ? normalizedMode : undefined, + isMelee: + Number.isFinite(normalizedMode) ? normalizedMode > 3 : undefined, + meleeHalfRest: false, }; ensureAudioAckListener(); @@ -596,6 +680,7 @@ export function connectMatchWebSocketFromNotice(notice, fallbackUserId) { 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, }); } diff --git a/src/pages/match-page.vue b/src/pages/match-page.vue index f02de99..9477314 100644 --- a/src/pages/match-page.vue +++ b/src/pages/match-page.vue @@ -12,6 +12,7 @@ const gameType = ref(0); const teamSize = ref(0); const onComplete = ref(null); const showLimitModal = ref(false); +const MATCH_READY_SNAPSHOT_PREFIX = "match-ready-snapshot:"; /** 匹配超时计时器,用于检测 WS 消息丢失或真正超时 */ const matchTimeoutTimer = ref(null); @@ -27,6 +28,17 @@ const clearMatchTimeout = () => { } }; +const cacheReadyBattle = (battle) => { + const matchId = String(battle?.matchId || ""); + if (!matchId) return; + + uni.setStorageSync(`${MATCH_READY_SNAPSHOT_PREFIX}${matchId}`, { + ...battle, + status: battle.status == null ? 0 : Number(battle.status), + savedAt: Date.now(), + }); +}; + /** * 超时处理:查询后端是否已分配对局 * - 有对局 → WS 消息丢失场景,自动跳入 @@ -36,6 +48,7 @@ const handleMatchTimeout = async () => { try { const battle = await getBattleAPI(); if (battle && battle.matchId) { + cacheReadyBattle(battle); uni.showToast({ title: "匹配成功,正在进入...", icon: "none" }); if (battle.mode <= 3) { uni.redirectTo({ url: `/pages/team-battle/index?battleId=${battle.matchId}` }); diff --git a/src/pages/melee-battle.vue b/src/pages/melee-battle.vue index 57b30e3..665fa7e 100644 --- a/src/pages/melee-battle.vue +++ b/src/pages/melee-battle.vue @@ -36,6 +36,7 @@ const playersSorted = ref([]); const playersScores = ref([]); const halfTimeTip = ref(false); const halfRest = ref(false); +const DEFAULT_READY_TIME = 15; const HALF_REST_SECONDS = 20; const MATCH_READY_SNAPSHOT_PREFIX = "match-ready-snapshot:"; const MATCH_STATUS_BY_TEXT = { @@ -46,6 +47,7 @@ const MATCH_STATUS_BY_TEXT = { MATCH_STATUS_UNEXPECTEDLY: 4, }; const halfRestRemain = ref(HALF_REST_SECONDS); +const readyTime = ref(DEFAULT_READY_TIME); let halfRestTimer = null; /** 控制设备离线提示弹窗的显示状态 */ const showOfflineModal = ref(false); @@ -132,6 +134,27 @@ function normalizeBattleInfo(battleInfo) { return battleInfo; } +function normalizeTimestamp(value) { + const numberValue = Number(value || 0); + if (!numberValue) return 0; + return numberValue < 1000000000000 ? numberValue * 1000 : numberValue; +} + +function getReadyTime(battleInfo) { + const value = Number(battleInfo?.readyTime); + return Number.isFinite(value) && value > 0 ? value : DEFAULT_READY_TIME; +} + +function getReadyRemainingSeconds(battleInfo) { + const total = getReadyTime(battleInfo); + const countdownStartTime = normalizeTimestamp(battleInfo?.countdownStartTime); + if (!countdownStartTime) return total; + + const elapsed = Math.max(0, (Date.now() - countdownStartTime) / 1000); + console.log('11111111111111111111111111111', total, countdownStartTime, Math.max(0, (Date.now() - countdownStartTime) / 1000)) + return Math.max(0, total - elapsed); +} + function hasKnownStatus(battleInfo) { return !( battleInfo?.status === undefined || @@ -168,6 +191,7 @@ function reconnectMatchServer(battleInfo) { serverAddr: battleInfo.serverAddr, matchId: battleInfo.matchId || battleId.value, userId: user.value.id, + mode: battleInfo.mode, }); } @@ -235,10 +259,10 @@ function recoverData(battleInfo, { force = false } = {}) { } if (battleInfo.status === 0) { - const readyRemain = (Date.now() - (battleInfo.serverTime || Date.now())) / 1000; - if (readyRemain > 0 && readyRemain < 15) { - setTimeout(() => uni.$emit("update-timer", 15 - readyRemain - 0.2), 200); - } + readyTime.value = getReadyTime(battleInfo); + setTimeout(() => { + uni.$emit("update-timer", getReadyRemainingSeconds(battleInfo)); + }, 200); return; } @@ -293,7 +317,11 @@ onLoad(async (options) => { return; } const readySnapshot = takeReadySnapshot(battleId.value); - if (readySnapshot?.status === 0) recoverData(readySnapshot); + if (readySnapshot?.status === 0) { + skipNextRestoreOnShow = true; + reconnectMatchServer(readySnapshot); + recoverData(readySnapshot); + } // uni.enableAlertBeforeUnload({ // message: "离开比赛可能导致比赛失败,是否继续?", // success: (res) => { @@ -426,7 +454,12 @@ onShow(async () => { - + { @@ -155,6 +157,21 @@ async function onComplete() { } } +async function exitPractise() { + if (exiting.value) return; + exiting.value = true; + + try { + if (practiseId.value && !practiseResult.value?.details) { + await endPractiseAPI(practiseId.value); + } + } catch (error) { + console.error("Failed to stop practice", error); + } finally { + uni.navigateBack(); + } +} + const getResultTipSrc = (result = {}) => { const validCount = (result.details || []).filter( (arrow) => arrow.x !== -30 && arrow.y !== -30 @@ -204,6 +221,7 @@ onBeforeUnmount(() => { :bgType="1" title="个人单组练习" :showBottom="!start && !scores.length" + :onBack="exitPractise" > diff --git a/src/pages/practise-two.vue b/src/pages/practise-two.vue index 7906200..ff32732 100644 --- a/src/pages/practise-two.vue +++ b/src/pages/practise-two.vue @@ -14,6 +14,7 @@ import audioManager from "@/audioManager"; import { createPractiseAPI, + endPractiseAPI, getPractiseAPI, startPractiseAPI, } from "@/apis"; @@ -39,6 +40,7 @@ const practiseId = ref(""); const showGuide = ref(false); const targetType = ref(1); const sharing = ref(false); +const exiting = ref(false); const RESULT_TIP_CDN = "https://static.shelingxingqiu.com/shootmini/static"; onLoad((options) => { @@ -170,6 +172,21 @@ async function onComplete() { } } +async function exitPractise() { + if (exiting.value) return; + exiting.value = true; + + try { + if (practiseId.value && !practiseResult.value?.details) { + await endPractiseAPI(practiseId.value); + } + } catch (error) { + console.error("Failed to stop practice", error); + } finally { + uni.navigateBack(); + } +} + const getResultTipSrc = (result = {}) => { const validCount = (result.details || []).filter( (arrow) => arrow.x !== -30 && arrow.y !== -30 @@ -218,6 +235,7 @@ onBeforeUnmount(() => { :bgType="1" title="日常耐力挑战" :showBottom="!start && !scores.length" + :onBack="exitPractise" > diff --git a/src/pages/team-battle/index.vue b/src/pages/team-battle/index.vue index cd748ac..9c0a9e0 100644 --- a/src/pages/team-battle/index.vue +++ b/src/pages/team-battle/index.vue @@ -32,7 +32,7 @@ const store = useStore(); const { user, online } = storeToRefs(store); const DEFAULT_SHOOT_TIME = 15; -const READY_SECONDS = 15; +const DEFAULT_READY_TIME = 15; const READY_TIMER_EMIT_DELAY = 200; const RESTORE_DELAY = 300; const RESTORE_EMPTY_ID_RETRY_DELAY = 50; @@ -81,6 +81,7 @@ const showRoundTip = ref(false); const isFinalShoot = ref(false); const matchStatus = ref(undefined); const shootTimeTotal = ref(DEFAULT_SHOOT_TIME); +const readyTime = ref(DEFAULT_READY_TIME); const showOfflineModal = ref(false); const restoreLoading = ref(false); const xRingStreaks = ref({}); @@ -144,6 +145,20 @@ function normalizeTimestamp(value) { return numberValue < 1000000000000 ? numberValue * 1000 : numberValue; } +function getReadyTime(battleInfo) { + const value = Number(battleInfo?.readyTime); + return Number.isFinite(value) && value > 0 ? value : DEFAULT_READY_TIME; +} + +function getReadyRemainingSeconds(battleInfo) { + const total = getReadyTime(battleInfo); + const countdownStartTime = normalizeTimestamp(battleInfo?.countdownStartTime); + if (!countdownStartTime) return total; + + const elapsed = Math.max(0, (Date.now() - countdownStartTime) / 1000); + return Math.max(0, total - elapsed); +} + function getReadySnapshotKey(matchId) { return `${MATCH_READY_SNAPSHOT_PREFIX}${String(matchId || "")}`; } @@ -830,6 +845,7 @@ function stopProgressAfterMount() { // 待开局状态:清理比赛态展示,并恢复准备倒计时。 function applyReadyState(battleInfo) { hideRestoreLoading(); + readyTime.value = getReadyTime(battleInfo); start.value = false; showRoundTip.value = false; currentShooterId.value = 0; @@ -841,13 +857,9 @@ function applyReadyState(battleInfo) { clearProgressZeroWaiters(); cancelRoundTipDisplay(); - const serverTime = normalizeTimestamp(battleInfo?.serverTime || Date.now()); - const readyElapsed = (Date.now() - serverTime) / 1000; - if (readyElapsed > 0 && readyElapsed < READY_SECONDS) { - setTimeout(() => { - uni.$emit("update-timer", READY_SECONDS - readyElapsed - 0.2); - }, READY_TIMER_EMIT_DELAY); - } + setTimeout(() => { + uni.$emit("update-timer", getReadyRemainingSeconds(battleInfo)); + }, READY_TIMER_EMIT_DELAY); } // 快照恢复入口:只把页面拉到服务端最新状态,不重放已经发生过的语音。 @@ -1346,7 +1358,12 @@ onShow(() => { :blueTeam="blueTeam" :winner="0" /> - +