diff --git a/src/matchWebsocket.js b/src/matchWebsocket.js index f47a29b..4902ce6 100644 --- a/src/matchWebsocket.js +++ b/src/matchWebsocket.js @@ -19,6 +19,10 @@ let audioAckListenerReady = false; // 后端要求非心跳消息必须等前端语音播报完成后再 ACK。 const pendingAcks = []; +// 当前仍是“只连接、只解码、只打印”阶段,还没有真正接管比赛语音播报。 +// 为了先验证协议闭环,这里临时把“打印完成”当作“播报完成”并立即 ACK。 +const AUTO_ACK_WITHOUT_AUDIO = true; + // 这些比赛消息需要在 audioEnded 后补 ACK;心跳 ACK 单独即时处理。 const ACK_REQUIRED_TYPES = new Set([ ServerMessageType.SERVER_MSG_MATCH_READY, @@ -36,6 +40,11 @@ function pickField(source, camelKey, snakeKey) { return source?.[camelKey] ?? source?.[snakeKey]; } +function normalizeId(value) { + if (value === undefined || value === null || value === "") return ""; + return String(value); +} + // 追加 token 查询参数;如果后端地址已经带 token,则保持原样。 function appendQuery(url, key, value) { if (!value || new RegExp(`[?&]${key}=`).test(url)) return url; @@ -87,14 +96,11 @@ function sendHeartbeatAck() { function sendAck({ matchId, sequence }) { // sequence 由后端处理,前端只原样带回,不做重排和断线补发。 - if (!sequence) return; + if (sequence === undefined || sequence === null || sequence === "") return; sendBuffer(createAckMessage({ matchId, sequence }), `ack ${sequence}`); } -function flushPendingAcks() { - // 每次语音播报完成,只确认一条已播放完成的服务端消息。 - if (!pendingAcks.length) return; - const task = pendingAcks.shift(); +function completeAckTask(task) { sendAck(task); if (task.leaveAfterAck) { setTimeout(() => { @@ -103,6 +109,12 @@ function flushPendingAcks() { } } +function flushPendingAcks() { + // 每次语音播报完成,只确认一条已播放完成的服务端消息。 + if (!pendingAcks.length) return; + completeAckTask(pendingAcks.shift()); +} + function ensureAudioAckListener() { // 复用现有全局 audioEnded 事件,确保 ACK 时机落在播报结束之后。 if (audioAckListenerReady) return; @@ -118,12 +130,33 @@ function removeAudioAckListener() { 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, + if ( + !ACK_REQUIRED_TYPES.has(message.type) || + 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, - }); + }; + + 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( "[match-ws] ack queued until audioEnded", getServerMessageTypeName(message.type), @@ -144,6 +177,11 @@ function handleMessage(data) { 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; @@ -168,16 +206,22 @@ 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 || !matchId) { + if (!url || !normalizedMatchId) { console.log("[match-ws] missing serverAddr or matchId", options); 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", { - matchId, + matchId: normalizedMatchId, url, }); return; @@ -188,13 +232,21 @@ export function connectMatchWebSocket(options = {}) { manualClose = false; isConnecting = true; - currentContext = { serverAddr, matchId, userId, url }; + currentContext = { + serverAddr, + matchId: normalizedMatchId, + userId: normalizedUserId, + url, + }; ensureAudioAckListener(); const socketTask = uni.connectSocket({ url, success: () => { - console.log("[match-ws] connect requested", { matchId, url }); + console.log("[match-ws] connect requested", { + matchId: normalizedMatchId, + url, + }); }, fail: (err) => { if (socket !== socketTask) return; @@ -209,7 +261,10 @@ export function connectMatchWebSocket(options = {}) { socketTask.onOpen(() => { if (socket !== socketTask) return; isConnecting = false; - console.log("[match-ws] connected", { matchId, url }); + console.log("[match-ws] connected", { + matchId: normalizedMatchId, + url, + }); }); socketTask.onMessage((res) => { diff --git a/src/utils/matchProtocol.js b/src/utils/matchProtocol.js index 07c384f..1c7c200 100644 --- a/src/utils/matchProtocol.js +++ b/src/utils/matchProtocol.js @@ -1,34 +1,199 @@ -import matchRoot from "./match.min.js"; +import protobuf from "protobufjs/minimal.js"; + +const { Reader, Writer } = protobuf; // 比赛服 protobuf 协议适配层: -// 这里只负责 raw protobuf frame 的解码和客户端消息编码,不处理 UI 状态。 -const ServerMessage = matchRoot.lookupType("rpc.ServerMessage"); -const ClientMessage = matchRoot.lookupType("rpc.ClientMessage"); +// 小程序环境不支持 protobufjs 反射模式里的动态 Function codegen, +// 所以这里使用 minimal Reader/Writer 做静态字段解码和客户端消息编码。 +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 中读取枚举,避免直接依赖生成文件内部结构。 -function getEnumValues(name) { - return matchRoot.lookupEnum(name).values || {}; -} +export const ClientMessageType = { + CLIENT_MSG_UNKNOWN: 0, + CLIENT_MSG_HEARTBEAT_ACK: 1, + CLIENT_MSG_SHOOT_DATA: 2, + CLIENT_MSG_ACK: 3, + CLIENT_MSG_LEAVE: 4, +}; // protobufjs 的 enum 默认是 name -> value,这里反转成 value -> name 用于日志打印。 -function invertEnum(values) { - return Object.keys(values).reduce((result, name) => { - result[values[name]] = name; +const ServerMessageTypeNameByValue = Object.keys(ServerMessageType).reduce( + (result, name) => { + result[ServerMessageType[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, +// 当前只需要解码比赛服下发的字段并打印,字段表按后端 proto 定义维护。 +const SCHEMAS = { + MatchShoot: { + 1: { name: "player_id", kind: "int64" }, + 2: { name: "status", kind: "int32" }, + 3: { name: "x", kind: "float" }, + 4: { name: "y", kind: "float" }, + 5: { name: "ring", kind: "int32" }, + 6: { name: "ring_x", kind: "bool" }, + 7: { name: "angle", kind: "float" }, + 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。 @@ -55,16 +220,122 @@ function toArrayBuffer(bytes) { 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) { - // 后端已确认比赛服 websocket 下发的是原始 protobuf frame,直接 ServerMessage.decode。 - const message = ServerMessage.decode(toUint8Array(data)); - return ServerMessage.toObject(message, TO_OBJECT_OPTIONS); + // 后端已确认比赛服 websocket 下发的是原始 protobuf frame,直接解 ServerMessage。 + return decodeMessage("ServerMessage", toUint8Array(data)); } export function encodeClientMessage(payload) { - // ClientMessage 字段名按 proto 定义传入,例如 match_id、user_id。 - const message = ClientMessage.create(payload); - return toArrayBuffer(ClientMessage.encode(message).finish()); + // ClientMessage 字段名按 proto 定义写入,例如 match_id、user_id。 + const writer = Writer.create(); + 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) {