Files
shoot-miniprograms/src/matchWebsocket.js
T
2026-07-09 11:43:06 +08:00

563 lines
17 KiB
JavaScript

import {
ClientMessageType,
ServerMessageType,
createAckMessage,
createHeartbeatAckMessage,
createLeaveMessage,
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 = "";
// 后端要求非心跳消息必须等前端语音播报完成后再 ACK。
const pendingAcks = [];
const ACK_AUDIO_TIMEOUT_MS = 9000;
const READY_ROUTE_FALLBACK_DELAY_MS = 300;
const ROUND_AUDIO_NAMES = ["一", "二", "三", "四", "五"];
// 比赛服消息类型先映射成项目里已有的 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 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 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,则保持原样。
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: () => {
if (label === "heartbeat ack") return;
console.log(`[match-ws] ${label} sent`, new Date());
},
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 === undefined || sequence === null || sequence === "") return;
sendBuffer(createAckMessage({ matchId, sequence }), `ack ${sequence}`);
}
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,都需要走 ACK;没有语音的消息立即 ACK。
if (
message.sequence === undefined ||
message.sequence === null ||
message.sequence === ""
) {
return;
}
const task = {
matchId: normalizeId(
pickField(message, "matchId", "match_id") || currentContext?.matchId
),
sequence: message.sequence,
leaveAfterAck: message.type === ServerMessageType.SERVER_MSG_MATCH_END,
};
const audioKeys = getAckAudioKeys(message, businessMessage).filter(Boolean);
if (!audioKeys.length) {
console.log(
"[match-ws] ack 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] 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,
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;
}
const typeName = getServerMessageTypeName(message.type);
console.log("[match-ws] message decoded", typeName, message);
const decodedMatchId = normalizeId(pickField(message, "matchId", "match_id"));
if (decodedMatchId && currentContext) {
currentContext.matchId = decodedMatchId;
}
if (message.type === ServerMessageType.SERVER_MSG_HEARTBEAT) {
sendHeartbeatAck();
return;
}
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;
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);
const normalizedMatchId = normalizeId(matchId);
const normalizedUserId = normalizeId(userId);
if (!url || !normalizedMatchId) {
console.log("[match-ws] missing serverAddr or matchId", options);
return;
}
if (
socket &&
currentContext?.url === url &&
currentContext?.matchId === normalizedMatchId
) {
// 同一场比赛同一地址重复通知时不重复建连。
console.log("[match-ws] already connected or connecting", {
matchId: normalizedMatchId,
url,
});
return;
}
// 切换比赛服地址时先关闭旧连接,不发送 LEAVE,避免误通知旧比赛离场。
closeMatchWebSocket({ sendLeave: false, reason: "switch" });
manualClose = false;
isConnecting = true;
currentContext = {
serverAddr,
matchId: normalizedMatchId,
userId: normalizedUserId,
url,
};
ensureAudioAckListener();
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);
},
});
socket = socketTask;
socketTask.onOpen(() => {
if (socket !== socketTask) return;
isConnecting = false;
console.log("[match-ws] connected", {
matchId: normalizedMatchId,
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.forEach((task) => {
if (task?.timer) clearTimeout(task.timer);
});
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,
};