update:更新比赛服逻辑

This commit is contained in:
2026-07-08 16:03:34 +08:00
parent 01ac4f9c03
commit e0d976fcce
2 changed files with 371 additions and 45 deletions
+70 -15
View File
@@ -19,6 +19,10 @@ let audioAckListenerReady = false;
// 后端要求非心跳消息必须等前端语音播报完成后再 ACK。 // 后端要求非心跳消息必须等前端语音播报完成后再 ACK。
const pendingAcks = []; const pendingAcks = [];
// 当前仍是“只连接、只解码、只打印”阶段,还没有真正接管比赛语音播报。
// 为了先验证协议闭环,这里临时把“打印完成”当作“播报完成”并立即 ACK。
const AUTO_ACK_WITHOUT_AUDIO = true;
// 这些比赛消息需要在 audioEnded 后补 ACK;心跳 ACK 单独即时处理。 // 这些比赛消息需要在 audioEnded 后补 ACK;心跳 ACK 单独即时处理。
const ACK_REQUIRED_TYPES = new Set([ const ACK_REQUIRED_TYPES = new Set([
ServerMessageType.SERVER_MSG_MATCH_READY, ServerMessageType.SERVER_MSG_MATCH_READY,
@@ -36,6 +40,11 @@ function pickField(source, camelKey, snakeKey) {
return source?.[camelKey] ?? source?.[snakeKey]; return source?.[camelKey] ?? source?.[snakeKey];
} }
function normalizeId(value) {
if (value === undefined || value === null || value === "") return "";
return String(value);
}
// 追加 token 查询参数;如果后端地址已经带 token,则保持原样。 // 追加 token 查询参数;如果后端地址已经带 token,则保持原样。
function appendQuery(url, key, value) { function appendQuery(url, key, value) {
if (!value || new RegExp(`[?&]${key}=`).test(url)) return url; if (!value || new RegExp(`[?&]${key}=`).test(url)) return url;
@@ -87,14 +96,11 @@ function sendHeartbeatAck() {
function sendAck({ matchId, sequence }) { function sendAck({ matchId, sequence }) {
// sequence 由后端处理,前端只原样带回,不做重排和断线补发。 // sequence 由后端处理,前端只原样带回,不做重排和断线补发。
if (!sequence) return; if (sequence === undefined || sequence === null || sequence === "") return;
sendBuffer(createAckMessage({ matchId, sequence }), `ack ${sequence}`); sendBuffer(createAckMessage({ matchId, sequence }), `ack ${sequence}`);
} }
function flushPendingAcks() { function completeAckTask(task) {
// 每次语音播报完成,只确认一条已播放完成的服务端消息。
if (!pendingAcks.length) return;
const task = pendingAcks.shift();
sendAck(task); sendAck(task);
if (task.leaveAfterAck) { if (task.leaveAfterAck) {
setTimeout(() => { setTimeout(() => {
@@ -103,6 +109,12 @@ function flushPendingAcks() {
} }
} }
function flushPendingAcks() {
// 每次语音播报完成,只确认一条已播放完成的服务端消息。
if (!pendingAcks.length) return;
completeAckTask(pendingAcks.shift());
}
function ensureAudioAckListener() { function ensureAudioAckListener() {
// 复用现有全局 audioEnded 事件,确保 ACK 时机落在播报结束之后。 // 复用现有全局 audioEnded 事件,确保 ACK 时机落在播报结束之后。
if (audioAckListenerReady) return; if (audioAckListenerReady) return;
@@ -118,12 +130,33 @@ function removeAudioAckListener() {
function queueAckAfterAudio(message) { function queueAckAfterAudio(message) {
// 非 ACK_REQUIRED_TYPES 的消息只打印,不自动确认,避免扩大发送面。 // 非 ACK_REQUIRED_TYPES 的消息只打印,不自动确认,避免扩大发送面。
if (!ACK_REQUIRED_TYPES.has(message.type) || !message.sequence) return; if (
pendingAcks.push({ !ACK_REQUIRED_TYPES.has(message.type) ||
matchId: pickField(message, "matchId", "match_id") || currentContext?.matchId, message.sequence === undefined ||
message.sequence === null ||
message.sequence === ""
) {
return;
}
const task = {
matchId: normalizeId(
pickField(message, "matchId", "match_id") || currentContext?.matchId
),
sequence: message.sequence, sequence: message.sequence,
leaveAfterAck: message.type === ServerMessageType.SERVER_MSG_MATCH_END, leaveAfterAck: message.type === ServerMessageType.SERVER_MSG_MATCH_END,
}); };
if (AUTO_ACK_WITHOUT_AUDIO) {
console.log(
"[match-ws] ack immediately in print-only mode",
getServerMessageTypeName(message.type),
message.sequence
);
completeAckTask(task);
return;
}
pendingAcks.push(task);
console.log( console.log(
"[match-ws] ack queued until audioEnded", "[match-ws] ack queued until audioEnded",
getServerMessageTypeName(message.type), getServerMessageTypeName(message.type),
@@ -144,6 +177,11 @@ function handleMessage(data) {
const typeName = getServerMessageTypeName(message.type); const typeName = getServerMessageTypeName(message.type);
console.log("[match-ws] message decoded", typeName, message); 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) { if (message.type === ServerMessageType.SERVER_MSG_HEARTBEAT) {
sendHeartbeatAck(); sendHeartbeatAck();
return; return;
@@ -168,16 +206,22 @@ export function connectMatchWebSocket(options = {}) {
// 入口参数来自原 websocket 的比赛服地址通知,不影响原有 websocket 连接。 // 入口参数来自原 websocket 的比赛服地址通知,不影响原有 websocket 连接。
const { serverAddr, matchId, userId, token } = options; const { serverAddr, matchId, userId, token } = options;
const url = normalizeServerUrl(serverAddr, token); const url = normalizeServerUrl(serverAddr, token);
const normalizedMatchId = normalizeId(matchId);
const normalizedUserId = normalizeId(userId);
if (!url || !matchId) { if (!url || !normalizedMatchId) {
console.log("[match-ws] missing serverAddr or matchId", options); console.log("[match-ws] missing serverAddr or matchId", options);
return; return;
} }
if (socket && currentContext?.url === url && currentContext?.matchId === matchId) { if (
socket &&
currentContext?.url === url &&
currentContext?.matchId === normalizedMatchId
) {
// 同一场比赛同一地址重复通知时不重复建连。 // 同一场比赛同一地址重复通知时不重复建连。
console.log("[match-ws] already connected or connecting", { console.log("[match-ws] already connected or connecting", {
matchId, matchId: normalizedMatchId,
url, url,
}); });
return; return;
@@ -188,13 +232,21 @@ export function connectMatchWebSocket(options = {}) {
manualClose = false; manualClose = false;
isConnecting = true; isConnecting = true;
currentContext = { serverAddr, matchId, userId, url }; currentContext = {
serverAddr,
matchId: normalizedMatchId,
userId: normalizedUserId,
url,
};
ensureAudioAckListener(); ensureAudioAckListener();
const socketTask = uni.connectSocket({ const socketTask = uni.connectSocket({
url, url,
success: () => { success: () => {
console.log("[match-ws] connect requested", { matchId, url }); console.log("[match-ws] connect requested", {
matchId: normalizedMatchId,
url,
});
}, },
fail: (err) => { fail: (err) => {
if (socket !== socketTask) return; if (socket !== socketTask) return;
@@ -209,7 +261,10 @@ export function connectMatchWebSocket(options = {}) {
socketTask.onOpen(() => { socketTask.onOpen(() => {
if (socket !== socketTask) return; if (socket !== socketTask) return;
isConnecting = false; isConnecting = false;
console.log("[match-ws] connected", { matchId, url }); console.log("[match-ws] connected", {
matchId: normalizedMatchId,
url,
});
}); });
socketTask.onMessage((res) => { socketTask.onMessage((res) => {
+301 -30
View File
@@ -1,34 +1,199 @@
import matchRoot from "./match.min.js"; import protobuf from "protobufjs/minimal.js";
const { Reader, Writer } = protobuf;
// 比赛服 protobuf 协议适配层: // 比赛服 protobuf 协议适配层:
// 这里只负责 raw protobuf frame 的解码和客户端消息编码,不处理 UI 状态。 // 小程序环境不支持 protobufjs 反射模式里的动态 Function codegen
const ServerMessage = matchRoot.lookupType("rpc.ServerMessage"); // 所以这里使用 minimal Reader/Writer 做静态字段解码和客户端消息编码。
const ClientMessage = matchRoot.lookupType("rpc.ClientMessage"); export const 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,
};
// 从反射 Root 中读取枚举,避免直接依赖生成文件内部结构。 export const ClientMessageType = {
function getEnumValues(name) { CLIENT_MSG_UNKNOWN: 0,
return matchRoot.lookupEnum(name).values || {}; CLIENT_MSG_HEARTBEAT_ACK: 1,
} CLIENT_MSG_SHOOT_DATA: 2,
CLIENT_MSG_ACK: 3,
CLIENT_MSG_LEAVE: 4,
};
// protobufjs 的 enum 默认是 name -> value,这里反转成 value -> name 用于日志打印。 // protobufjs 的 enum 默认是 name -> value,这里反转成 value -> name 用于日志打印。
function invertEnum(values) { const ServerMessageTypeNameByValue = Object.keys(ServerMessageType).reduce(
return Object.keys(values).reduce((result, name) => { (result, name) => {
result[values[name]] = name; result[ServerMessageType[name]] = name;
return result; return result;
}, {}); },
} {}
);
export const ServerMessageType = getEnumValues("rpc.ServerMessageType"); // 当前只需要解码比赛服下发的字段并打印,字段表按后端 proto 定义维护。
export const ClientMessageType = getEnumValues("rpc.ClientMessageType"); const SCHEMAS = {
MatchShoot: {
const ServerMessageTypeNameByValue = invertEnum(ServerMessageType); 1: { name: "player_id", kind: "int64" },
2: { name: "status", kind: "int32" },
// 统一把 int64 转成字符串,避免小程序环境下大整数精度丢失。 3: { name: "x", kind: "float" },
const TO_OBJECT_OPTIONS = { 4: { name: "y", kind: "float" },
longs: String, 5: { name: "ring", kind: "int32" },
enums: Number, 6: { name: "ring_x", kind: "bool" },
bytes: Array, 7: { name: "angle", kind: "float" },
oneofs: true, 8: { name: "distance", kind: "float" },
},
MatchShootList: {
1: { name: "items", kind: "message", type: "MatchShoot", repeated: true },
},
RoundScore: {
1: { name: "total_ring", kind: "int32" },
2: { name: "score", kind: "int32" },
3: { name: "if_win", kind: "bool" },
},
MatchRound: {
1: {
name: "shoots",
kind: "map",
keyKind: "int64",
valueKind: "message",
valueType: "MatchShootList",
},
2: {
name: "scores",
kind: "map",
keyKind: "int32",
valueKind: "message",
valueType: "RoundScore",
},
3: { name: "round", kind: "int32" },
4: { name: "if_gold", kind: "bool" },
5: { name: "status", kind: "int32" },
6: { name: "gold_round", kind: "int32" },
},
PlayerMatchResult: {
1: { name: "total_ring", kind: "int32" },
2: { name: "user_id", kind: "int64" },
3: { name: "ten_ring_count", kind: "int32" },
4: { name: "average_ring", kind: "float" },
},
PlayerFull: {
1: { name: "id", kind: "int64" },
2: { name: "name", kind: "string" },
3: { name: "avatar", kind: "string" },
4: { name: "exp", kind: "int32" },
5: { name: "score", kind: "int32" },
6: { name: "level", kind: "int32" },
7: { name: "before_level", kind: "int32" },
8: { name: "before_exp", kind: "int32" },
9: { name: "current_exp", kind: "int32" },
10: { name: "upgrade_exp", kind: "int32" },
11: { name: "active", kind: "int32" },
12: { name: "device_id", kind: "string" },
13: { name: "s_vip", kind: "bool" },
14: { name: "vip", kind: "bool" },
15: { name: "player_match_result", kind: "message", type: "PlayerMatchResult" },
},
TeamInfo: {
1: { name: "players", kind: "message", type: "PlayerFull", repeated: true },
2: { name: "id", kind: "int32" },
3: { name: "name", kind: "string" },
4: { name: "score", kind: "int32" },
},
CurrentShoot: {
1: { name: "round", kind: "int32" },
2: { name: "round_id", kind: "int64" },
3: { name: "index", kind: "int32" },
4: { name: "start_time", kind: "int64" },
5: { name: "player_id", kind: "int64" },
6: { name: "gold_round", kind: "bool" },
7: { name: "start_time_text", kind: "string" },
8: { name: "my_index", kind: "int32" },
9: {
name: "index_map",
kind: "map",
keyKind: "int64",
valueKind: "int32",
},
},
ShootData: {
1: { name: "x", kind: "float" },
2: { name: "y", kind: "float" },
3: { name: "r", kind: "float" },
4: { name: "dst", kind: "float" },
5: { name: "m", kind: "string" },
6: { name: "adc", kind: "float" },
7: { name: "device_id", kind: "string" },
8: { name: "shoot_id", kind: "string" },
},
PracticeInfo: {
1: { name: "id", kind: "int64" },
2: { name: "user_id", kind: "int64" },
3: { name: "status", kind: "int32" },
4: { name: "status_text", kind: "string" },
5: { name: "start_time", kind: "int64" },
6: { name: "target_type", kind: "int32" },
7: { name: "vip", kind: "bool" },
8: { name: "s_vip", kind: "bool" },
9: { name: "device_id", kind: "string" },
10: { name: "shoot_data", kind: "message", type: "MatchShoot" },
11: { name: "details", kind: "message", type: "MatchShoot", repeated: true },
},
MatchInfo: {
1: { name: "match_id", kind: "int64" },
2: { name: "create_time", kind: "int64" },
3: { name: "start_time", kind: "int64" },
4: { name: "server_time", kind: "int64" },
5: { name: "shoot_time", kind: "int32" },
6: { name: "shoot_number", kind: "int32" },
7: { name: "ready_time", kind: "int32" },
8: { name: "way", kind: "int32" },
9: { name: "mode", kind: "int32" },
10: { name: "status", kind: "int32" },
11: { name: "status_text", kind: "string" },
12: { name: "rounds", kind: "message", type: "MatchRound", repeated: true },
13: {
name: "teams",
kind: "map",
keyKind: "int32",
valueKind: "message",
valueType: "TeamInfo",
},
14: { name: "current", kind: "message", type: "CurrentShoot" },
15: { name: "next", kind: "message", type: "CurrentShoot" },
16: { name: "shoot_data", kind: "message", type: "MatchShoot" },
17: { name: "win_team", kind: "int32" },
18: { name: "mvp", kind: "message", type: "PlayerFull" },
19: { name: "room_id", kind: "string" },
20: {
name: "result_list",
kind: "message",
type: "PlayerMatchResult",
repeated: true,
},
21: { name: "timeout_time", kind: "int64" },
22: { name: "target_type", kind: "int32" },
23: { name: "event_type", kind: "int32" },
24: { name: "timeout", kind: "int32" },
25: { name: "server_addr", kind: "string" },
},
ServerMessage: {
1: { name: "type", kind: "int32" },
2: { name: "match_id", kind: "int64" },
3: { name: "timestamp", kind: "int64" },
4: { name: "match_info", kind: "message", type: "MatchInfo", oneof: "payload" },
5: { name: "shoot_data", kind: "message", type: "ShootData", oneof: "payload" },
6: { name: "practice_info", kind: "message", type: "PracticeInfo", oneof: "payload" },
7: { name: "sequence", kind: "int64" },
},
}; };
// 小程序 websocket 收到的 data 可能是 ArrayBuffer、TypedArray 或字符串,这里统一成 Uint8Array。 // 小程序 websocket 收到的 data 可能是 ArrayBuffer、TypedArray 或字符串,这里统一成 Uint8Array。
@@ -55,16 +220,122 @@ function toArrayBuffer(bytes) {
return output.buffer; return output.buffer;
} }
function readScalar(reader, kind) {
switch (kind) {
case "int32":
return reader.int32();
case "int64":
// 统一把 int64 转成字符串,避免小程序环境下大整数精度丢失。
return reader.int64().toString();
case "float":
return reader.float();
case "bool":
return reader.bool();
case "string":
return reader.string();
case "bytes":
return Array.from(reader.bytes());
default:
throw new Error(`Unsupported protobuf scalar kind: ${kind}`);
}
}
function readMapEntry(reader, field) {
const end = reader.uint32() + reader.pos;
let key = "";
let value;
while (reader.pos < end) {
const tag = reader.uint32();
switch (tag >>> 3) {
case 1:
key = readScalar(reader, field.keyKind);
break;
case 2:
value =
field.valueKind === "message"
? decodeMessage(field.valueType, reader, reader.uint32())
: readScalar(reader, field.valueKind);
break;
default:
reader.skipType(tag & 7);
break;
}
}
return { key: String(key), value };
}
function readValue(reader, field) {
if (field.kind === "message") {
return decodeMessage(field.type, reader, reader.uint32());
}
return readScalar(reader, field.kind);
}
function decodeMessage(schemaName, readerOrData, length) {
const schema = SCHEMAS[schemaName];
const reader =
readerOrData instanceof Reader ? readerOrData : Reader.create(readerOrData);
const end = length === undefined ? reader.len : reader.pos + length;
const message = {};
while (reader.pos < end) {
const tag = reader.uint32();
const field = schema[tag >>> 3];
if (!field) {
reader.skipType(tag & 7);
continue;
}
if (field.kind === "map") {
const entry = readMapEntry(reader, field);
const map = message[field.name] || {};
map[entry.key] = entry.value;
message[field.name] = map;
continue;
}
const value = readValue(reader, field);
if (field.repeated) {
if (!message[field.name]) message[field.name] = [];
message[field.name].push(value);
} else {
message[field.name] = value;
}
if (field.oneof) {
message[field.oneof] = field.name;
}
}
return message;
}
export function decodeServerMessage(data) { export function decodeServerMessage(data) {
// 后端已确认比赛服 websocket 下发的是原始 protobuf frame,直接 ServerMessage.decode // 后端已确认比赛服 websocket 下发的是原始 protobuf frame,直接 ServerMessage。
const message = ServerMessage.decode(toUint8Array(data)); return decodeMessage("ServerMessage", toUint8Array(data));
return ServerMessage.toObject(message, TO_OBJECT_OPTIONS);
} }
export function encodeClientMessage(payload) { export function encodeClientMessage(payload) {
// ClientMessage 字段名按 proto 定义入,例如 match_id、user_id。 // ClientMessage 字段名按 proto 定义入,例如 match_id、user_id。
const message = ClientMessage.create(payload); const writer = Writer.create();
return toArrayBuffer(ClientMessage.encode(message).finish()); const matchId = payload.match_id ?? payload.matchId;
const userId = payload.user_id ?? payload.userId;
if (payload.type !== undefined) writer.uint32(8).int32(payload.type);
if (matchId !== undefined && matchId !== null) writer.uint32(16).int64(matchId);
if (userId !== undefined && userId !== null) writer.uint32(24).int64(userId);
if (payload.sequence !== undefined && payload.sequence !== null) {
writer.uint32(32).int64(payload.sequence);
}
if (payload.data !== undefined && payload.data !== null) {
writer.uint32(42).bytes(toUint8Array(payload.data));
}
return toArrayBuffer(writer.finish());
} }
export function getServerMessageTypeName(type) { export function getServerMessageTypeName(type) {