1074 lines
32 KiB
JavaScript
1074 lines
32 KiB
JavaScript
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 [];
|
|
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 ["比赛结束"];
|
|
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,
|
|
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,
|
|
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,
|
|
};
|