update:优化比赛队列
This commit is contained in:
+256
-29
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user