update:新增比赛服链接
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
import {
|
||||
ClientMessageType,
|
||||
ServerMessageType,
|
||||
createAckMessage,
|
||||
createHeartbeatAckMessage,
|
||||
createLeaveMessage,
|
||||
decodeServerMessage,
|
||||
getServerMessageTypeName,
|
||||
} from "@/utils/matchProtocol";
|
||||
|
||||
// 比赛服 websocket 独立管理器:
|
||||
// 当前阶段只负责连接、解码、打印、ACK/LEAVE,不接管任何页面 UI。
|
||||
let socket = null;
|
||||
let currentContext = null;
|
||||
let isConnecting = false;
|
||||
let manualClose = false;
|
||||
let audioAckListenerReady = false;
|
||||
|
||||
// 后端要求非心跳消息必须等前端语音播报完成后再 ACK。
|
||||
const pendingAcks = [];
|
||||
|
||||
// 这些比赛消息需要在 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,
|
||||
]);
|
||||
|
||||
// 兼容普通接口通知的 camelCase 和 protobuf 解码后的 snake_case。
|
||||
function pickField(source, camelKey, snakeKey) {
|
||||
return source?.[camelKey] ?? source?.[snakeKey];
|
||||
}
|
||||
|
||||
// 追加 token 查询参数;如果后端地址已经带 token,则保持原样。
|
||||
function appendQuery(url, key, value) {
|
||||
if (!value || new RegExp(`[?&]${key}=`).test(url)) return url;
|
||||
return `${url}${url.includes("?") ? "&" : "?"}${key}=${encodeURIComponent(
|
||||
value
|
||||
)}`;
|
||||
}
|
||||
|
||||
// 后端可能下发完整 ws 地址,也可能只给 host:port,这里统一转成可连接地址。
|
||||
function normalizeServerUrl(serverAddr, token) {
|
||||
if (!serverAddr || typeof serverAddr !== "string") return "";
|
||||
|
||||
let url = serverAddr.trim();
|
||||
if (!url) return "";
|
||||
|
||||
if (!/^wss?:\/\//i.test(url)) {
|
||||
const [address, query = ""] = url.split("?");
|
||||
const hasPath = address.includes("/");
|
||||
const host = hasPath
|
||||
? address
|
||||
: `${address}${address.includes(":") ? "" : ":8011"}/ws`;
|
||||
url = `ws://${host}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
return appendQuery(url, "token", token);
|
||||
}
|
||||
|
||||
// 统一发送二进制 protobuf 消息,并在日志里保留发送类型。
|
||||
function sendBuffer(buffer, label) {
|
||||
if (!socket || !buffer) return;
|
||||
|
||||
const currentSocket = socket;
|
||||
currentSocket.send({
|
||||
data: buffer,
|
||||
success: () => {
|
||||
console.log(`[match-ws] ${label} sent`);
|
||||
},
|
||||
fail: (err) => {
|
||||
if (socket !== currentSocket) return;
|
||||
console.log(`[match-ws] ${label} send failed`, err);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function sendHeartbeatAck() {
|
||||
// 心跳 ACK 不进入 pendingAcks,收到后立即回复。
|
||||
sendBuffer(createHeartbeatAckMessage(), "heartbeat ack");
|
||||
}
|
||||
|
||||
function sendAck({ matchId, sequence }) {
|
||||
// sequence 由后端处理,前端只原样带回,不做重排和断线补发。
|
||||
if (!sequence) return;
|
||||
sendBuffer(createAckMessage({ matchId, sequence }), `ack ${sequence}`);
|
||||
}
|
||||
|
||||
function flushPendingAcks() {
|
||||
// 每次语音播报完成,只确认一条已播放完成的服务端消息。
|
||||
if (!pendingAcks.length) return;
|
||||
const task = pendingAcks.shift();
|
||||
sendAck(task);
|
||||
if (task.leaveAfterAck) {
|
||||
setTimeout(() => {
|
||||
closeMatchWebSocket({ reason: "match-end" });
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureAudioAckListener() {
|
||||
// 复用现有全局 audioEnded 事件,确保 ACK 时机落在播报结束之后。
|
||||
if (audioAckListenerReady) return;
|
||||
uni.$on("audioEnded", flushPendingAcks);
|
||||
audioAckListenerReady = true;
|
||||
}
|
||||
|
||||
function removeAudioAckListener() {
|
||||
if (!audioAckListenerReady) return;
|
||||
uni.$off("audioEnded", flushPendingAcks);
|
||||
audioAckListenerReady = false;
|
||||
}
|
||||
|
||||
function queueAckAfterAudio(message) {
|
||||
// 非 ACK_REQUIRED_TYPES 的消息只打印,不自动确认,避免扩大发送面。
|
||||
if (!ACK_REQUIRED_TYPES.has(message.type) || !message.sequence) return;
|
||||
pendingAcks.push({
|
||||
matchId: pickField(message, "matchId", "match_id") || currentContext?.matchId,
|
||||
sequence: message.sequence,
|
||||
leaveAfterAck: message.type === ServerMessageType.SERVER_MSG_MATCH_END,
|
||||
});
|
||||
console.log(
|
||||
"[match-ws] ack queued until audioEnded",
|
||||
getServerMessageTypeName(message.type),
|
||||
message.sequence
|
||||
);
|
||||
}
|
||||
|
||||
function handleMessage(data) {
|
||||
// 比赛服下发 raw protobuf frame,先解码,再根据消息类型决定是否 ACK。
|
||||
let message;
|
||||
try {
|
||||
message = decodeServerMessage(data);
|
||||
} catch (err) {
|
||||
console.log("[match-ws] decode failed", err, data);
|
||||
return;
|
||||
}
|
||||
|
||||
const typeName = getServerMessageTypeName(message.type);
|
||||
console.log("[match-ws] message decoded", typeName, message);
|
||||
|
||||
if (message.type === ServerMessageType.SERVER_MSG_HEARTBEAT) {
|
||||
sendHeartbeatAck();
|
||||
return;
|
||||
}
|
||||
|
||||
queueAckAfterAudio(message);
|
||||
}
|
||||
|
||||
function sendLeave() {
|
||||
// 主动关闭、比赛结束、页面离开时发 CLIENT_MSG_LEAVE。
|
||||
if (!socket || !currentContext?.matchId) return;
|
||||
sendBuffer(
|
||||
createLeaveMessage({
|
||||
matchId: currentContext.matchId,
|
||||
userId: currentContext.userId,
|
||||
}),
|
||||
"leave"
|
||||
);
|
||||
}
|
||||
|
||||
export function connectMatchWebSocket(options = {}) {
|
||||
// 入口参数来自原 websocket 的比赛服地址通知,不影响原有 websocket 连接。
|
||||
const { serverAddr, matchId, userId, token } = options;
|
||||
const url = normalizeServerUrl(serverAddr, token);
|
||||
|
||||
if (!url || !matchId) {
|
||||
console.log("[match-ws] missing serverAddr or matchId", options);
|
||||
return;
|
||||
}
|
||||
|
||||
if (socket && currentContext?.url === url && currentContext?.matchId === matchId) {
|
||||
// 同一场比赛同一地址重复通知时不重复建连。
|
||||
console.log("[match-ws] already connected or connecting", {
|
||||
matchId,
|
||||
url,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 切换比赛服地址时先关闭旧连接,不发送 LEAVE,避免误通知旧比赛离场。
|
||||
closeMatchWebSocket({ sendLeave: false, reason: "switch" });
|
||||
|
||||
manualClose = false;
|
||||
isConnecting = true;
|
||||
currentContext = { serverAddr, matchId, userId, url };
|
||||
ensureAudioAckListener();
|
||||
|
||||
const socketTask = uni.connectSocket({
|
||||
url,
|
||||
success: () => {
|
||||
console.log("[match-ws] connect requested", { matchId, url });
|
||||
},
|
||||
fail: (err) => {
|
||||
if (socket !== socketTask) return;
|
||||
socket = null;
|
||||
isConnecting = false;
|
||||
console.log("[match-ws] connect failed", err);
|
||||
},
|
||||
});
|
||||
|
||||
socket = socketTask;
|
||||
|
||||
socketTask.onOpen(() => {
|
||||
if (socket !== socketTask) return;
|
||||
isConnecting = false;
|
||||
console.log("[match-ws] connected", { matchId, url });
|
||||
});
|
||||
|
||||
socketTask.onMessage((res) => {
|
||||
if (socket !== socketTask) return;
|
||||
handleMessage(res.data);
|
||||
});
|
||||
|
||||
socketTask.onError((err) => {
|
||||
if (socket !== socketTask) return;
|
||||
console.log("[match-ws] socket error", err);
|
||||
});
|
||||
|
||||
socketTask.onClose((result) => {
|
||||
if (socket !== socketTask) return;
|
||||
socket = null;
|
||||
isConnecting = false;
|
||||
console.log("[match-ws] closed", {
|
||||
manualClose,
|
||||
result,
|
||||
context: currentContext,
|
||||
});
|
||||
if (!manualClose) removeAudioAckListener();
|
||||
});
|
||||
}
|
||||
|
||||
export function connectMatchWebSocketFromNotice(notice, fallbackUserId) {
|
||||
// 原 websocket 下发的比赛服通知统一从这里转成连接参数。
|
||||
if (!notice) return;
|
||||
connectMatchWebSocket({
|
||||
serverAddr: pickField(notice, "serverAddr", "server_addr"),
|
||||
matchId: pickField(notice, "matchId", "match_id"),
|
||||
userId: pickField(notice, "userId", "user_id") || fallbackUserId,
|
||||
token: notice.token || notice.wsToken || notice.ws_token,
|
||||
});
|
||||
}
|
||||
|
||||
export function closeMatchWebSocket(options = {}) {
|
||||
// 默认关闭时会发送 LEAVE;内部切换连接可通过 sendLeave:false 跳过。
|
||||
const { sendLeave: shouldSendLeave = true, reason = "manual" } = options;
|
||||
|
||||
manualClose = true;
|
||||
pendingAcks.length = 0;
|
||||
|
||||
if (socket) {
|
||||
const currentSocket = socket;
|
||||
if (shouldSendLeave) sendLeave();
|
||||
socket = null;
|
||||
try {
|
||||
currentSocket.close();
|
||||
} catch (err) {
|
||||
console.log("[match-ws] close failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
isConnecting = false;
|
||||
removeAudioAckListener();
|
||||
console.log("[match-ws] close requested", { reason, currentContext });
|
||||
currentContext = null;
|
||||
}
|
||||
|
||||
export default {
|
||||
ClientMessageType,
|
||||
ServerMessageType,
|
||||
connectMatchWebSocket,
|
||||
connectMatchWebSocketFromNotice,
|
||||
closeMatchWebSocket,
|
||||
};
|
||||
Reference in New Issue
Block a user