update:新增比赛服链接

This commit is contained in:
2026-07-08 14:51:35 +08:00
parent 348d490de8
commit 01ac4f9c03
10 changed files with 3160 additions and 326 deletions
+26
View File
@@ -7,9 +7,13 @@
onHide
} from "@dcloudio/uni-app";
import websocket from "@/websocket";
import matchWebsocket from "@/matchWebsocket";
import {
getDeviceBatteryAPI
} from "@/apis";
import {
MESSAGETYPES
} from "@/constants";
import useStore from "@/store";
import {
storeToRefs
@@ -36,6 +40,9 @@
websocket.createWebSocket(token, onShootWsMsg);
}
if (!newVal) {
matchWebsocket.closeMatchWebSocket({
reason: "logout"
});
websocket.closeWebSocket();
}
}, {
@@ -73,10 +80,26 @@
// audioManager.play("射箭声音")
}
function connectMatchServerFromMessage(content) {
const messages = Array.isArray(content) ? content : [content];
messages.forEach((message) => {
if (
message?.constructor ===
MESSAGETYPES.ShootSyncBattleCreateMatchLinkID
) {
matchWebsocket.connectMatchWebSocketFromNotice(
message,
user.value.id
);
}
});
}
function onShootWsMsg(content) {
if(content.type === 'shoot-trigger'){
onDeviceShoot()
}
connectMatchServerFromMessage(content);
uni.$emit("socket-inbox", content);
}
@@ -99,6 +122,9 @@
uni.$off("update-online", emitUpdateOnline);
uni.$off("session-kicked-out", onSessionKickedOut);
uni.$off("device-bind-invalid", onDeviceBindInvalid);
matchWebsocket.closeMatchWebSocket({
reason: "app-hide"
});
websocket.closeWebSocket();
});
</script>
+2 -2
View File
@@ -6,8 +6,8 @@ try {
switch (envVersion) {
case "develop": // 开发版
// BASE_URL = "http://192.168.1.30: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";
+1
View File
@@ -1,6 +1,7 @@
export const MESSAGETYPES = {
ShootSyncMeArrowID: parseInt("0x789b6b0d"), // 2023451405
ShootSyncMePracticeID: parseInt("0xD88AE05E"), // 3632980062
ShootSyncBattleCreateMatchLinkID: parseInt("0xF86FF466"), // 2538869862
WaitForAllReady: parseInt("0x615C13BE"), // 1633424318
AllReady: parseInt("0x1CCB49FD"), // 483084797
MeleeAllReady: parseInt("0x37132BD5"), // 924003285
+279
View File
@@ -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,
};
+57
View File
@@ -0,0 +1,57 @@
/* eslint-disable */
import * as $protobuf from "protobufjs";
if (typeof $protobuf.Root.create !== "function") {
const isObject = (value) => value && typeof value === "object" && !Array.isArray(value);
const normalizeField = (field) => {
if (!isObject(field)) return field;
const next = { ...field };
if (next.keytype && !next.keyType) {
next.keyType = next.keytype;
delete next.keytype;
}
delete next.oneof;
return next;
};
const normalizeType = (json) => {
const next = { ...json };
if (next.fields) {
const oneofs = { ...(next.oneofs || {}) };
next.fields = Object.keys(next.fields).reduce((fields, name) => {
const field = next.fields[name];
fields[name] = normalizeField(field);
if (field && field.oneof) {
if (!oneofs[field.oneof]) oneofs[field.oneof] = { oneof: [] };
oneofs[field.oneof].oneof.push(name);
}
return fields;
}, {});
if (Object.keys(oneofs).length) next.oneofs = oneofs;
}
if (next.nested) next.nested = normalizeNested(next.nested);
return next;
};
const normalizeDescriptor = (json) => {
if (!isObject(json)) return json;
if (json.fields) return normalizeType(json);
if (json.values || json.methods || json.id !== undefined) return json;
if (Object.keys(json).every((key) => typeof json[key] === "number")) {
return { values: json };
}
if (json.nested) return { ...json, nested: normalizeNested(json.nested) };
return { nested: normalizeNested(json) };
};
const normalizeNested = (nested) =>
Object.keys(nested || {}).reduce((result, name) => {
result[name] = normalizeDescriptor(nested[name]);
return result;
}, {});
$protobuf.Root.create = (json) => $protobuf.Root.fromJSON(normalizeDescriptor(json));
}
const $root=$protobuf.Root.create({nested:{rpc:{ServerMessageType:{SERVER_MSG_UNKNOWN:0,SERVER_MSG_MATCH_READY:1,SERVER_MSG_MATCH_START:2,SERVER_MSG_NOW_YOU:3,SERVER_MSG_SHOT:4,SERVER_MSG_NEW_ROUND:5,SERVER_MSG_MATCH_END:6,SERVER_MSG_TIMEOUT:7,SERVER_MSG_CHECK:8,SERVER_MSG_RAND:9,SERVER_MSG_NOT_ENOUGH_DISTANCE:10,SERVER_MSG_PLAYER_LEFT:11,SERVER_MSG_HEARTBEAT:12,SERVER_MSG_PRACTICE_END:13},ClientMessageType:{CLIENT_MSG_UNKNOWN:0,CLIENT_MSG_HEARTBEAT_ACK:1,CLIENT_MSG_SHOOT_DATA:2,CLIENT_MSG_ACK:3,CLIENT_MSG_LEAVE:4},MatchStatus:{MATCH_STATUS_READY:0,MATCH_STATUS_STARTED:1,MATCH_STATUS_END:2,MATCH_STATUS_TIMEOUT:3,MATCH_STATUS_UNEXPECTEDLY:4},MatchPlayerStatus:{MATCH_PLAYER_STATUS_READY:0,MATCH_PLAYER_STATUS_STARTED:1,MATCH_PLAYER_STATUS_END:2},MatchShoot:{fields:{player_id:{type:"int64",id:1},status:{type:"int32",id:2},x:{type:"float",id:3},y:{type:"float",id:4},ring:{type:"int32",id:5},ring_x:{type:"bool",id:6},angle:{type:"float",id:7},distance:{type:"float",id:8}}},MatchShootList:{fields:{items:{rule:"repeated",type:"MatchShoot",id:1}}},RoundScore:{fields:{total_ring:{type:"int32",id:1},score:{type:"int32",id:2},if_win:{type:"bool",id:3}}},MatchRound:{fields:{shoots:{rule:"map",type:"MatchShootList",id:1,keytype:"int64"},scores:{rule:"map",type:"RoundScore",id:2,keytype:"int32"},round:{type:"int32",id:3},if_gold:{type:"bool",id:4},status:{type:"int32",id:5},gold_round:{type:"int32",id:6}}},PlayerMatchResult:{fields:{total_ring:{type:"int32",id:1},user_id:{type:"int64",id:2},ten_ring_count:{type:"int32",id:3},average_ring:{type:"float",id:4}}},PlayerFull:{fields:{id:{type:"int64",id:1},name:{type:"string",id:2},avatar:{type:"string",id:3},exp:{type:"int32",id:4},score:{type:"int32",id:5},level:{type:"int32",id:6},before_level:{type:"int32",id:7},before_exp:{type:"int32",id:8},current_exp:{type:"int32",id:9},upgrade_exp:{type:"int32",id:10},active:{type:"int32",id:11},device_id:{type:"string",id:12},s_vip:{type:"bool",id:13},vip:{type:"bool",id:14},player_match_result:{type:"PlayerMatchResult",id:15}}},TeamInfo:{fields:{players:{rule:"repeated",type:"PlayerFull",id:1},id:{type:"int32",id:2},name:{type:"string",id:3},score:{type:"int32",id:4}}},CurrentShoot:{fields:{round:{type:"int32",id:1},round_id:{type:"int64",id:2},index:{type:"int32",id:3},start_time:{type:"int64",id:4},player_id:{type:"int64",id:5},gold_round:{type:"bool",id:6},start_time_text:{type:"string",id:7},my_index:{type:"int32",id:8},index_map:{rule:"map",type:"int32",id:9,keytype:"int64"}}},ShootData:{fields:{x:{type:"float",id:1},y:{type:"float",id:2},r:{type:"float",id:3},dst:{type:"float",id:4},m:{type:"string",id:5},adc:{type:"float",id:6},device_id:{type:"string",id:7},shoot_id:{type:"string",id:8}}},PracticeInfo:{fields:{id:{type:"int64",id:1},user_id:{type:"int64",id:2},status:{type:"int32",id:3},status_text:{type:"string",id:4},start_time:{type:"int64",id:5},target_type:{type:"int32",id:6},vip:{type:"bool",id:7},s_vip:{type:"bool",id:8},device_id:{type:"string",id:9},shoot_data:{type:"MatchShoot",id:10},details:{rule:"repeated",type:"MatchShoot",id:11}}},MatchInfo:{fields:{match_id:{type:"int64",id:1},create_time:{type:"int64",id:2},start_time:{type:"int64",id:3},server_time:{type:"int64",id:4},shoot_time:{type:"int32",id:5},shoot_number:{type:"int32",id:6},ready_time:{type:"int32",id:7},way:{type:"int32",id:8},mode:{type:"int32",id:9},status:{type:"MatchStatus",id:10},status_text:{type:"string",id:11},rounds:{rule:"repeated",type:"MatchRound",id:12},teams:{rule:"map",type:"TeamInfo",id:13,keytype:"int32"},current:{type:"CurrentShoot",id:14},next:{type:"CurrentShoot",id:15},shoot_data:{type:"MatchShoot",id:16},win_team:{type:"int32",id:17},mvp:{type:"PlayerFull",id:18},room_id:{type:"string",id:19},result_list:{rule:"repeated",type:"PlayerMatchResult",id:20},timeout_time:{type:"int64",id:21},target_type:{type:"int32",id:22},event_type:{type:"int32",id:23},timeout:{type:"int32",id:24},server_addr:{type:"string",id:25}}},ServerMessage:{fields:{type:{type:"ServerMessageType",id:1},match_id:{type:"int64",id:2},timestamp:{type:"int64",id:3},match_info:{type:"MatchInfo",id:4,oneof:"payload"},shoot_data:{type:"ShootData",id:5,oneof:"payload"},practice_info:{type:"PracticeInfo",id:6,oneof:"payload"},sequence:{type:"int64",id:7}}},ClientMessage:{fields:{type:{type:"ClientMessageType",id:1},match_id:{type:"int64",id:2},user_id:{type:"int64",id:3},sequence:{type:"int64",id:4},data:{type:"bytes",id:5}}}}}});
export default $root;
+97
View File
@@ -0,0 +1,97 @@
import matchRoot from "./match.min.js";
// 比赛服 protobuf 协议适配层:
// 这里只负责 raw protobuf frame 的解码和客户端消息编码,不处理 UI 状态。
const ServerMessage = matchRoot.lookupType("rpc.ServerMessage");
const ClientMessage = matchRoot.lookupType("rpc.ClientMessage");
// 从反射 Root 中读取枚举,避免直接依赖生成文件内部结构。
function getEnumValues(name) {
return matchRoot.lookupEnum(name).values || {};
}
// protobufjs 的 enum 默认是 name -> value,这里反转成 value -> name 用于日志打印。
function invertEnum(values) {
return Object.keys(values).reduce((result, name) => {
result[values[name]] = name;
return result;
}, {});
}
export const ServerMessageType = getEnumValues("rpc.ServerMessageType");
export const ClientMessageType = getEnumValues("rpc.ClientMessageType");
const ServerMessageTypeNameByValue = invertEnum(ServerMessageType);
// 统一把 int64 转成字符串,避免小程序环境下大整数精度丢失。
const TO_OBJECT_OPTIONS = {
longs: String,
enums: Number,
bytes: Array,
oneofs: true,
};
// 小程序 websocket 收到的 data 可能是 ArrayBuffer、TypedArray 或字符串,这里统一成 Uint8Array。
function toUint8Array(data) {
if (data instanceof Uint8Array) return data;
if (data instanceof ArrayBuffer) return new Uint8Array(data);
if (ArrayBuffer.isView(data)) {
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
}
if (typeof data === "string") {
const bytes = new Uint8Array(data.length);
for (let i = 0; i < data.length; i += 1) {
bytes[i] = data.charCodeAt(i) & 0xff;
}
return bytes;
}
throw new TypeError("Unsupported match websocket message payload");
}
// uni.sendSocketMessage 发送二进制时使用 ArrayBuffer。
function toArrayBuffer(bytes) {
const output = new Uint8Array(bytes.length);
output.set(bytes);
return output.buffer;
}
export function decodeServerMessage(data) {
// 后端已确认比赛服 websocket 下发的是原始 protobuf frame,直接 ServerMessage.decode。
const message = ServerMessage.decode(toUint8Array(data));
return ServerMessage.toObject(message, TO_OBJECT_OPTIONS);
}
export function encodeClientMessage(payload) {
// ClientMessage 字段名按 proto 定义传入,例如 match_id、user_id。
const message = ClientMessage.create(payload);
return toArrayBuffer(ClientMessage.encode(message).finish());
}
export function getServerMessageTypeName(type) {
return ServerMessageTypeNameByValue[type] || String(type);
}
export function createHeartbeatAckMessage() {
// 心跳 ACK 按文档立即回,不等待语音播报。
return encodeClientMessage({
type: ClientMessageType.CLIENT_MSG_HEARTBEAT_ACK,
});
}
export function createAckMessage({ matchId, sequence }) {
// 普通消息 ACK 仍携带 sequence,但前端不做 sequence 补发或排序。
return encodeClientMessage({
type: ClientMessageType.CLIENT_MSG_ACK,
match_id: matchId,
sequence,
});
}
export function createLeaveMessage({ matchId, userId }) {
// 比赛结束或页面离开时通知比赛服释放当前玩家连接。
return encodeClientMessage({
type: ClientMessageType.CLIENT_MSG_LEAVE,
match_id: matchId,
user_id: userId,
});
}
+2 -2
View File
@@ -24,8 +24,8 @@ function createWebSocket(token, onMessage) {
switch (envVersion) {
case "develop": // 开发版
// url = "ws://192.168.1.30:8000/socket";
url = "wss://apitest.shelingxingqiu.com/socket";
url = "ws://192.168.1.2:8000/socket";
// url = "wss://apitest.shelingxingqiu.com/socket";
break;
case "trial": // 体验版
url = "wss://apitest.shelingxingqiu.com/socket";