update:优化比赛队列
This commit is contained in:
+4
-1
@@ -1,3 +1,5 @@
|
|||||||
|
import { normalizeBattleApiResult } from "@/utils/matchAdapter";
|
||||||
|
|
||||||
let BASE_URL = "https://api.shelingxingqiu.com/api/shoot"; // 默认正式版
|
let BASE_URL = "https://api.shelingxingqiu.com/api/shoot"; // 默认正式版
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -553,9 +555,10 @@ export const getReadyAPI = (roomId) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getBattleAPI = async (battleId) => {
|
export const getBattleAPI = async (battleId) => {
|
||||||
return request("POST", "/user/match/info", {
|
const result = await request("POST", "/user/match/info", {
|
||||||
id: battleId,
|
id: battleId,
|
||||||
});
|
});
|
||||||
|
return normalizeBattleApiResult(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const kickPlayerAPI = (number, userId) => {
|
export const kickPlayerAPI = (number, userId) => {
|
||||||
|
|||||||
+256
-29
@@ -7,42 +7,220 @@ import {
|
|||||||
decodeServerMessage,
|
decodeServerMessage,
|
||||||
getServerMessageTypeName,
|
getServerMessageTypeName,
|
||||||
} from "@/utils/matchProtocol";
|
} from "@/utils/matchProtocol";
|
||||||
|
import { MESSAGETYPESV2 } from "@/constants";
|
||||||
|
import { getDirectionText } from "@/util";
|
||||||
|
import {
|
||||||
|
normalizeId,
|
||||||
|
normalizeMatchInfo,
|
||||||
|
normalizePlainObject,
|
||||||
|
pickField,
|
||||||
|
} from "@/utils/matchAdapter";
|
||||||
|
|
||||||
// 比赛服 websocket 独立管理器:
|
// 比赛服 websocket 独立管理器:
|
||||||
// 当前阶段只负责连接、解码、打印、ACK/LEAVE,不接管任何页面 UI。
|
// 负责连接、解码、ACK/LEAVE,并把比赛服消息适配成项目现有 socket-inbox 业务事件。
|
||||||
let socket = null;
|
let socket = null;
|
||||||
let currentContext = null;
|
let currentContext = null;
|
||||||
let isConnecting = false;
|
let isConnecting = false;
|
||||||
let manualClose = false;
|
let manualClose = false;
|
||||||
let audioAckListenerReady = false;
|
let audioAckListenerReady = false;
|
||||||
|
let lastReadyRouteKey = "";
|
||||||
|
|
||||||
// 后端要求非心跳消息必须等前端语音播报完成后再 ACK。
|
// 后端要求非心跳消息必须等前端语音播报完成后再 ACK。
|
||||||
const pendingAcks = [];
|
const pendingAcks = [];
|
||||||
|
const ACK_AUDIO_TIMEOUT_MS = 9000;
|
||||||
|
const READY_ROUTE_FALLBACK_DELAY_MS = 300;
|
||||||
|
const ROUND_AUDIO_NAMES = ["一", "二", "三", "四", "五"];
|
||||||
|
|
||||||
// 当前仍是“只连接、只解码、只打印”阶段,还没有真正接管比赛语音播报。
|
// 比赛服消息类型先映射成项目里已有的 V2 业务消息,页面仍然复用原来的 socket-inbox 流程。
|
||||||
// 为了先验证协议闭环,这里临时把“打印完成”当作“播报完成”并立即 ACK。
|
const BUSINESS_TYPE_BY_SERVER_TYPE = {
|
||||||
const AUTO_ACK_WITHOUT_AUDIO = true;
|
[ServerMessageType.SERVER_MSG_MATCH_READY]: MESSAGETYPESV2.AboutToStart,
|
||||||
|
[ServerMessageType.SERVER_MSG_MATCH_START]: MESSAGETYPESV2.BattleStart,
|
||||||
// 这些比赛消息需要在 audioEnded 后补 ACK;心跳 ACK 单独即时处理。
|
[ServerMessageType.SERVER_MSG_NOW_YOU]: MESSAGETYPESV2.ToSomeoneShoot,
|
||||||
const ACK_REQUIRED_TYPES = new Set([
|
[ServerMessageType.SERVER_MSG_SHOT]: MESSAGETYPESV2.ShootResult,
|
||||||
ServerMessageType.SERVER_MSG_MATCH_READY,
|
[ServerMessageType.SERVER_MSG_NEW_ROUND]: MESSAGETYPESV2.NewRound,
|
||||||
ServerMessageType.SERVER_MSG_MATCH_START,
|
[ServerMessageType.SERVER_MSG_MATCH_END]: MESSAGETYPESV2.BattleEnd,
|
||||||
ServerMessageType.SERVER_MSG_NOW_YOU,
|
[ServerMessageType.SERVER_MSG_TIMEOUT]: MESSAGETYPESV2.BattleEnd,
|
||||||
ServerMessageType.SERVER_MSG_SHOT,
|
[ServerMessageType.SERVER_MSG_CHECK]: MESSAGETYPESV2.InvalidShot,
|
||||||
ServerMessageType.SERVER_MSG_NEW_ROUND,
|
[ServerMessageType.SERVER_MSG_NOT_ENOUGH_DISTANCE]: MESSAGETYPESV2.InvalidShot,
|
||||||
ServerMessageType.SERVER_MSG_MATCH_END,
|
};
|
||||||
ServerMessageType.SERVER_MSG_TIMEOUT,
|
|
||||||
ServerMessageType.SERVER_MSG_CHECK,
|
|
||||||
]);
|
|
||||||
|
|
||||||
|
const MATCH_READY_SNAPSHOT_PREFIX = "match-ready-snapshot:";
|
||||||
|
export const MATCH_WS_AUDIO_ACK_EVENT = "match-ws-audio-ack";
|
||||||
// 兼容普通接口通知的 camelCase 和 protobuf 解码后的 snake_case。
|
// 兼容普通接口通知的 camelCase 和 protobuf 解码后的 snake_case。
|
||||||
function pickField(source, camelKey, snakeKey) {
|
function buildBusinessMessage(message) {
|
||||||
return source?.[camelKey] ?? source?.[snakeKey];
|
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 normalizeId(value) {
|
function getReadySnapshotKey(matchId) {
|
||||||
if (value === undefined || value === null || value === "") return "";
|
return `${MATCH_READY_SNAPSHOT_PREFIX}${normalizeId(matchId)}`;
|
||||||
return String(value);
|
}
|
||||||
|
|
||||||
|
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,则保持原样。
|
// 追加 token 查询参数;如果后端地址已经带 token,则保持原样。
|
||||||
@@ -101,6 +279,7 @@ function sendAck({ matchId, sequence }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function completeAckTask(task) {
|
function completeAckTask(task) {
|
||||||
|
if (task?.timer) clearTimeout(task.timer);
|
||||||
sendAck(task);
|
sendAck(task);
|
||||||
if (task.leaveAfterAck) {
|
if (task.leaveAfterAck) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -109,9 +288,33 @@ function completeAckTask(task) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
function flushPendingAcks() {
|
||||||
// 每次语音播报完成,只确认一条已播放完成的服务端消息。
|
// 每次语音播报完成,只确认队首那条等待相同语音 key 的服务端消息。
|
||||||
if (!pendingAcks.length) return;
|
if (!pendingAcks.length) return;
|
||||||
|
const endedKey = arguments[0];
|
||||||
|
const task = pendingAcks[0];
|
||||||
|
if (task.expectedAudioKey && endedKey && task.expectedAudioKey !== endedKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
completeAckTask(pendingAcks.shift());
|
completeAckTask(pendingAcks.shift());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,19 +322,20 @@ function ensureAudioAckListener() {
|
|||||||
// 复用现有全局 audioEnded 事件,确保 ACK 时机落在播报结束之后。
|
// 复用现有全局 audioEnded 事件,确保 ACK 时机落在播报结束之后。
|
||||||
if (audioAckListenerReady) return;
|
if (audioAckListenerReady) return;
|
||||||
uni.$on("audioEnded", flushPendingAcks);
|
uni.$on("audioEnded", flushPendingAcks);
|
||||||
|
uni.$on(MATCH_WS_AUDIO_ACK_EVENT, completePendingAckByMessage);
|
||||||
audioAckListenerReady = true;
|
audioAckListenerReady = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAudioAckListener() {
|
function removeAudioAckListener() {
|
||||||
if (!audioAckListenerReady) return;
|
if (!audioAckListenerReady) return;
|
||||||
uni.$off("audioEnded", flushPendingAcks);
|
uni.$off("audioEnded", flushPendingAcks);
|
||||||
|
uni.$off(MATCH_WS_AUDIO_ACK_EVENT, completePendingAckByMessage);
|
||||||
audioAckListenerReady = false;
|
audioAckListenerReady = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function queueAckAfterAudio(message) {
|
function queueAckAfterAudio(message, businessMessage) {
|
||||||
// 非 ACK_REQUIRED_TYPES 的消息只打印,不自动确认,避免扩大发送面。
|
// 除心跳外,只要服务端带了 sequence,都需要走 ACK;没有语音的消息立即 ACK。
|
||||||
if (
|
if (
|
||||||
!ACK_REQUIRED_TYPES.has(message.type) ||
|
|
||||||
message.sequence === undefined ||
|
message.sequence === undefined ||
|
||||||
message.sequence === null ||
|
message.sequence === null ||
|
||||||
message.sequence === ""
|
message.sequence === ""
|
||||||
@@ -146,9 +350,10 @@ function queueAckAfterAudio(message) {
|
|||||||
leaveAfterAck: message.type === ServerMessageType.SERVER_MSG_MATCH_END,
|
leaveAfterAck: message.type === ServerMessageType.SERVER_MSG_MATCH_END,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (AUTO_ACK_WITHOUT_AUDIO) {
|
const audioKeys = getAckAudioKeys(message, businessMessage).filter(Boolean);
|
||||||
|
if (!audioKeys.length) {
|
||||||
console.log(
|
console.log(
|
||||||
"[match-ws] ack immediately in print-only mode",
|
"[match-ws] ack immediately without audio",
|
||||||
getServerMessageTypeName(message.type),
|
getServerMessageTypeName(message.type),
|
||||||
message.sequence
|
message.sequence
|
||||||
);
|
);
|
||||||
@@ -156,11 +361,25 @@ function queueAckAfterAudio(message) {
|
|||||||
return;
|
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);
|
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),
|
||||||
message.sequence
|
message.sequence,
|
||||||
|
task.expectedAudioKey
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +406,12 @@ function handleMessage(data) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
queueAckAfterAudio(message);
|
const businessMessage = buildBusinessMessage(message);
|
||||||
|
if (businessMessage?.matchId && currentContext) {
|
||||||
|
currentContext.matchId = businessMessage.matchId;
|
||||||
|
}
|
||||||
|
queueAckAfterAudio(message, businessMessage);
|
||||||
|
emitBusinessMessage(businessMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendLeave() {
|
function sendLeave() {
|
||||||
@@ -306,6 +530,9 @@ export function closeMatchWebSocket(options = {}) {
|
|||||||
const { sendLeave: shouldSendLeave = true, reason = "manual" } = options;
|
const { sendLeave: shouldSendLeave = true, reason = "manual" } = options;
|
||||||
|
|
||||||
manualClose = true;
|
manualClose = true;
|
||||||
|
pendingAcks.forEach((task) => {
|
||||||
|
if (task?.timer) clearTimeout(task.timer);
|
||||||
|
});
|
||||||
pendingAcks.length = 0;
|
pendingAcks.length = 0;
|
||||||
|
|
||||||
if (socket) {
|
if (socket) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import TestDistance from "@/components/TestDistance.vue";
|
|||||||
import SModal from "@/components/SModal.vue";
|
import SModal from "@/components/SModal.vue";
|
||||||
import audioManager from "@/audioManager";
|
import audioManager from "@/audioManager";
|
||||||
import { getBattleAPI, laserCloseAPI } from "@/apis";
|
import { getBattleAPI, laserCloseAPI } from "@/apis";
|
||||||
|
import { connectMatchWebSocket } from "@/matchWebsocket";
|
||||||
import { MESSAGETYPESV2 } from "@/constants";
|
import { MESSAGETYPESV2 } from "@/constants";
|
||||||
import useStore from "@/store";
|
import useStore from "@/store";
|
||||||
import { storeToRefs } from "pinia";
|
import { storeToRefs } from "pinia";
|
||||||
@@ -31,6 +32,14 @@ const playersScores = ref([]);
|
|||||||
const halfTimeTip = ref(false);
|
const halfTimeTip = ref(false);
|
||||||
const halfRest = ref(false);
|
const halfRest = ref(false);
|
||||||
const HALF_REST_SECONDS = 20;
|
const HALF_REST_SECONDS = 20;
|
||||||
|
const MATCH_READY_SNAPSHOT_PREFIX = "match-ready-snapshot:";
|
||||||
|
const MATCH_STATUS_BY_TEXT = {
|
||||||
|
MATCH_STATUS_READY: 0,
|
||||||
|
MATCH_STATUS_STARTED: 1,
|
||||||
|
MATCH_STATUS_END: 2,
|
||||||
|
MATCH_STATUS_TIMEOUT: 3,
|
||||||
|
MATCH_STATUS_UNEXPECTEDLY: 4,
|
||||||
|
};
|
||||||
const halfRestRemain = ref(HALF_REST_SECONDS);
|
const halfRestRemain = ref(HALF_REST_SECONDS);
|
||||||
let halfRestTimer = null;
|
let halfRestTimer = null;
|
||||||
/** 控制设备离线提示弹窗的显示状态 */
|
/** 控制设备离线提示弹窗的显示状态 */
|
||||||
@@ -93,6 +102,68 @@ const currentPlayer = computed(() =>
|
|||||||
);
|
);
|
||||||
const isCurrentUserSvip = computed(() => currentPlayer.value?.sVip === true);
|
const isCurrentUserSvip = computed(() => currentPlayer.value?.sVip === true);
|
||||||
|
|
||||||
|
function getReadySnapshotKey(matchId) {
|
||||||
|
return `${MATCH_READY_SNAPSHOT_PREFIX}${String(matchId || "")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBattleInfo(battleInfo) {
|
||||||
|
if (!battleInfo || typeof battleInfo !== "object") return battleInfo;
|
||||||
|
|
||||||
|
const statusText = battleInfo.statusText || battleInfo.status_text;
|
||||||
|
if (
|
||||||
|
(battleInfo.status === undefined ||
|
||||||
|
battleInfo.status === null ||
|
||||||
|
battleInfo.status === "") &&
|
||||||
|
MATCH_STATUS_BY_TEXT[statusText] !== undefined
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...battleInfo,
|
||||||
|
status: MATCH_STATUS_BY_TEXT[statusText],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return battleInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasKnownStatus(battleInfo) {
|
||||||
|
return !(
|
||||||
|
battleInfo?.status === undefined ||
|
||||||
|
battleInfo?.status === null ||
|
||||||
|
battleInfo?.status === ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasStartedSnapshot(battleInfo) {
|
||||||
|
if (hasKnownStatus(battleInfo)) return Number(battleInfo.status) !== 0;
|
||||||
|
|
||||||
|
const current = battleInfo?.current || {};
|
||||||
|
return !!(
|
||||||
|
current.playerId ||
|
||||||
|
current.startTime ||
|
||||||
|
current.round ||
|
||||||
|
(Array.isArray(battleInfo?.rounds) && battleInfo.rounds.length)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeReadySnapshot(matchId) {
|
||||||
|
const key = getReadySnapshotKey(matchId);
|
||||||
|
const snapshot = uni.getStorageSync(key);
|
||||||
|
if (snapshot) uni.removeStorageSync(key);
|
||||||
|
return normalizeBattleInfo(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconnectMatchServer(battleInfo) {
|
||||||
|
const status = Number(battleInfo?.status);
|
||||||
|
if ([2, 3, 4].includes(status)) return;
|
||||||
|
if (!battleInfo?.serverAddr) return;
|
||||||
|
|
||||||
|
connectMatchWebSocket({
|
||||||
|
serverAddr: battleInfo.serverAddr,
|
||||||
|
matchId: battleInfo.matchId || battleId.value,
|
||||||
|
userId: user.value.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 监听设备在线状态,大乱斗比赛进行中设备离线时弹窗提示用户
|
* 监听设备在线状态,大乱斗比赛进行中设备离线时弹窗提示用户
|
||||||
*/
|
*/
|
||||||
@@ -103,6 +174,7 @@ watch(online, (newVal, oldVal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function recoverData(battleInfo, { force = false } = {}) {
|
function recoverData(battleInfo, { force = false } = {}) {
|
||||||
|
battleInfo = normalizeBattleInfo(battleInfo);
|
||||||
if (!battleInfo) return;
|
if (!battleInfo) return;
|
||||||
try {
|
try {
|
||||||
if (battleInfo.way === 1) title.value = "好友约战 - 大乱斗";
|
if (battleInfo.way === 1) title.value = "好友约战 - 大乱斗";
|
||||||
@@ -121,7 +193,14 @@ function recoverData(battleInfo, { force = false } = {}) {
|
|||||||
players.value = [];
|
players.value = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasKnownStatus(battleInfo)) {
|
||||||
start.value = battleInfo.status !== 0;
|
start.value = battleInfo.status !== 0;
|
||||||
|
} else if (!hasStartedSnapshot(battleInfo)) {
|
||||||
|
if (start.value !== true) start.value = false;
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
start.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (battleInfo.status === 0) {
|
if (battleInfo.status === 0) {
|
||||||
const readyRemain = (Date.now() - (battleInfo.createTime || Date.now())) / 1000;
|
const readyRemain = (Date.now() - (battleInfo.createTime || Date.now())) / 1000;
|
||||||
@@ -131,9 +210,10 @@ function recoverData(battleInfo, { force = false } = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const rounds = Array.isArray(battleInfo.rounds) ? battleInfo.rounds : [];
|
||||||
tips.value =
|
tips.value =
|
||||||
(battleInfo.rounds.length !== 2 ? "上" : "下") + "半场:请先射6箭";
|
(rounds.length !== 2 ? "上" : "下") + "半场:请先射6箭";
|
||||||
playersScores.value = battleInfo.rounds.map((r) => ({ ...r.shoots }));
|
playersScores.value = rounds.map((r) => ({ ...r.shoots }));
|
||||||
const totals = {};
|
const totals = {};
|
||||||
players.value.forEach((p) => {
|
players.value.forEach((p) => {
|
||||||
const total = playersScores.value.reduce((acc, round) => {
|
const total = playersScores.value.reduce((acc, round) => {
|
||||||
@@ -171,6 +251,8 @@ function recoverData(battleInfo, { force = false } = {}) {
|
|||||||
|
|
||||||
onLoad(async (options) => {
|
onLoad(async (options) => {
|
||||||
if (options.battleId) battleId.value = options.battleId;
|
if (options.battleId) battleId.value = options.battleId;
|
||||||
|
const readySnapshot = takeReadySnapshot(battleId.value);
|
||||||
|
if (readySnapshot?.status === 0) recoverData(readySnapshot);
|
||||||
// uni.enableAlertBeforeUnload({
|
// uni.enableAlertBeforeUnload({
|
||||||
// message: "离开比赛可能导致比赛失败,是否继续?",
|
// message: "离开比赛可能导致比赛失败,是否继续?",
|
||||||
// success: (res) => {
|
// success: (res) => {
|
||||||
@@ -207,7 +289,9 @@ function checkAndPlayTententen(playerId, isTenPlusRingShot) {
|
|||||||
|
|
||||||
async function onReceiveMessage(msg) {
|
async function onReceiveMessage(msg) {
|
||||||
if (Array.isArray(msg)) return;
|
if (Array.isArray(msg)) return;
|
||||||
if (msg.type === MESSAGETYPESV2.BattleStart) {
|
if (msg.type === MESSAGETYPESV2.AboutToStart) {
|
||||||
|
recoverData(msg);
|
||||||
|
} else if (msg.type === MESSAGETYPESV2.BattleStart) {
|
||||||
clearHalfRestCountdown();
|
clearHalfRestCountdown();
|
||||||
halfTimeTip.value = false;
|
halfTimeTip.value = false;
|
||||||
halfRest.value = false;
|
halfRest.value = false;
|
||||||
@@ -267,7 +351,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
onShow(async () => {
|
onShow(async () => {
|
||||||
if (battleId.value) {
|
if (battleId.value) {
|
||||||
const result = await getBattleAPI(battleId.value);
|
const result = normalizeBattleInfo(await getBattleAPI(battleId.value));
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
if (result.status === 2) {
|
if (result.status === 2) {
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
@@ -278,6 +362,7 @@ onShow(async () => {
|
|||||||
delta: 2,
|
delta: 2,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
reconnectMatchServer(result);
|
||||||
recoverData(result, { force: true });
|
recoverData(result, { force: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import TeamAvatars from "./components/TeamAvatars.vue";
|
|||||||
import ShootProgress2 from "./components/ShootProgress2.vue";
|
import ShootProgress2 from "./components/ShootProgress2.vue";
|
||||||
import SModal from "./components/SModal.vue";
|
import SModal from "./components/SModal.vue";
|
||||||
import { laserCloseAPI, getBattleAPI } from "@/apis";
|
import { laserCloseAPI, getBattleAPI } from "@/apis";
|
||||||
|
import { connectMatchWebSocket, MATCH_WS_AUDIO_ACK_EVENT } from "@/matchWebsocket";
|
||||||
import { MESSAGETYPESV2 } from "@/constants";
|
import { MESSAGETYPESV2 } from "@/constants";
|
||||||
import { getDirectionText } from "@/util";
|
import { getDirectionText } from "@/util";
|
||||||
import audioManager, {
|
import audioManager, {
|
||||||
@@ -42,6 +43,14 @@ const AUDIO_TIMEOUT_MAX = 12000;
|
|||||||
const BATTLE_CANCEL_RETURN_DELAY = 2000;
|
const BATTLE_CANCEL_RETURN_DELAY = 2000;
|
||||||
const ROUND_AUDIO_NAMES = ["一", "二", "三", "四", "五"];
|
const ROUND_AUDIO_NAMES = ["一", "二", "三", "四", "五"];
|
||||||
const X_RING_STREAKS_KEY = "team-battle-x-ring-streaks";
|
const X_RING_STREAKS_KEY = "team-battle-x-ring-streaks";
|
||||||
|
const MATCH_READY_SNAPSHOT_PREFIX = "match-ready-snapshot:";
|
||||||
|
const MATCH_STATUS_BY_TEXT = {
|
||||||
|
MATCH_STATUS_READY: 0,
|
||||||
|
MATCH_STATUS_STARTED: 1,
|
||||||
|
MATCH_STATUS_END: 2,
|
||||||
|
MATCH_STATUS_TIMEOUT: 3,
|
||||||
|
MATCH_STATUS_UNEXPECTEDLY: 4,
|
||||||
|
};
|
||||||
const PROGRESS_ZERO_EVENT = "team-battle-progress-zero";
|
const PROGRESS_ZERO_EVENT = "team-battle-progress-zero";
|
||||||
const COUNTDOWN_READY_EVENT = "team-battle-countdown-ready";
|
const COUNTDOWN_READY_EVENT = "team-battle-countdown-ready";
|
||||||
|
|
||||||
@@ -129,6 +138,68 @@ function normalizeTimestamp(value) {
|
|||||||
return numberValue < 1000000000000 ? numberValue * 1000 : numberValue;
|
return numberValue < 1000000000000 ? numberValue * 1000 : numberValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getReadySnapshotKey(matchId) {
|
||||||
|
return `${MATCH_READY_SNAPSHOT_PREFIX}${String(matchId || "")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBattleInfo(battleInfo) {
|
||||||
|
if (!battleInfo || typeof battleInfo !== "object") return battleInfo;
|
||||||
|
|
||||||
|
const statusText = battleInfo.statusText || battleInfo.status_text;
|
||||||
|
if (
|
||||||
|
(battleInfo.status === undefined ||
|
||||||
|
battleInfo.status === null ||
|
||||||
|
battleInfo.status === "") &&
|
||||||
|
MATCH_STATUS_BY_TEXT[statusText] !== undefined
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
...battleInfo,
|
||||||
|
status: MATCH_STATUS_BY_TEXT[statusText],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return battleInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasKnownStatus(battleInfo) {
|
||||||
|
return !(
|
||||||
|
battleInfo?.status === undefined ||
|
||||||
|
battleInfo?.status === null ||
|
||||||
|
battleInfo?.status === ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasStartedSnapshot(battleInfo) {
|
||||||
|
if (hasKnownStatus(battleInfo)) return Number(battleInfo.status) !== 0;
|
||||||
|
|
||||||
|
const current = battleInfo?.current || {};
|
||||||
|
return !!(
|
||||||
|
current.playerId ||
|
||||||
|
current.startTime ||
|
||||||
|
current.round ||
|
||||||
|
(Array.isArray(battleInfo?.rounds) && battleInfo.rounds.length)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeReadySnapshot(matchId) {
|
||||||
|
const key = getReadySnapshotKey(matchId);
|
||||||
|
const snapshot = uni.getStorageSync(key);
|
||||||
|
if (snapshot) uni.removeStorageSync(key);
|
||||||
|
return normalizeBattleInfo(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconnectMatchServer(battleInfo) {
|
||||||
|
const status = Number(battleInfo?.status);
|
||||||
|
if ([2, 3, 4].includes(status)) return;
|
||||||
|
if (!battleInfo?.serverAddr) return;
|
||||||
|
|
||||||
|
connectMatchWebSocket({
|
||||||
|
serverAddr: battleInfo.serverAddr,
|
||||||
|
matchId: battleInfo.matchId || battleId.value,
|
||||||
|
userId: user.value.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 从不同消息结构中提取服务端时间,作为恢复和去重的时间基准。
|
// 从不同消息结构中提取服务端时间,作为恢复和去重的时间基准。
|
||||||
function getServerTime(message) {
|
function getServerTime(message) {
|
||||||
return normalizeTimestamp(
|
return normalizeTimestamp(
|
||||||
@@ -289,6 +360,10 @@ function hideRestoreLoading() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showRestoreLoading() {
|
function showRestoreLoading() {
|
||||||
|
if (start.value === false) {
|
||||||
|
hideRestoreLoading();
|
||||||
|
return;
|
||||||
|
}
|
||||||
clearRestoreLoadingTimer();
|
clearRestoreLoadingTimer();
|
||||||
restoreLoading.value = true;
|
restoreLoading.value = true;
|
||||||
restoreLoadingTimer = setTimeout(() => {
|
restoreLoadingTimer = setTimeout(() => {
|
||||||
@@ -391,7 +466,9 @@ async function executeBattleTask(task, runId) {
|
|||||||
if (!task || !isQueueAlive(runId)) return;
|
if (!task || !isQueueAlive(runId)) return;
|
||||||
const type = task.type;
|
const type = task.type;
|
||||||
|
|
||||||
if (type === MESSAGETYPESV2.BattleStart) {
|
if (type === MESSAGETYPESV2.AboutToStart) {
|
||||||
|
applyBattleSnapshot(task.message, { restore: true });
|
||||||
|
} else if (type === MESSAGETYPESV2.BattleStart) {
|
||||||
await runBattleStartTask(task, runId);
|
await runBattleStartTask(task, runId);
|
||||||
} else if (type === MESSAGETYPESV2.ToSomeoneShoot) {
|
} else if (type === MESSAGETYPESV2.ToSomeoneShoot) {
|
||||||
await runToSomeoneShootTask(task, runId);
|
await runToSomeoneShootTask(task, runId);
|
||||||
@@ -402,7 +479,7 @@ async function executeBattleTask(task, runId) {
|
|||||||
} else if (type === MESSAGETYPESV2.BattleEnd) {
|
} else if (type === MESSAGETYPESV2.BattleEnd) {
|
||||||
await runBattleEndTask(task, runId);
|
await runBattleEndTask(task, runId);
|
||||||
} else if (type === MESSAGETYPESV2.InvalidShot) {
|
} else if (type === MESSAGETYPESV2.InvalidShot) {
|
||||||
await runInvalidShotTask(runId);
|
await runInvalidShotTask(task, runId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -448,6 +525,24 @@ function onAudioEnded(key) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notifyMatchAudioAck(task) {
|
||||||
|
const message = task?.message;
|
||||||
|
if (
|
||||||
|
message?.sequence === undefined ||
|
||||||
|
message?.sequence === null ||
|
||||||
|
message?.sequence === ""
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
uni.$emit(MATCH_WS_AUDIO_ACK_EVENT, {
|
||||||
|
matchId: message.matchId || battleId.value,
|
||||||
|
sequence: message.sequence,
|
||||||
|
matchWsType: message.matchWsType,
|
||||||
|
matchWsTypeName: message.matchWsTypeName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function handleBattleCovered() {
|
function handleBattleCovered() {
|
||||||
if (pendingRestoreTimer) {
|
if (pendingRestoreTimer) {
|
||||||
clearTimeout(pendingRestoreTimer);
|
clearTimeout(pendingRestoreTimer);
|
||||||
@@ -526,6 +621,7 @@ function applyRestoreNewRoundSnapshot(battleInfo) {
|
|||||||
|
|
||||||
// 回填比赛基础信息:队伍、比分、轮次、金箭状态等公共字段都在这里统一处理。
|
// 回填比赛基础信息:队伍、比分、轮次、金箭状态等公共字段都在这里统一处理。
|
||||||
function applyBattleBase(battleInfo) {
|
function applyBattleBase(battleInfo) {
|
||||||
|
battleInfo = normalizeBattleInfo(battleInfo);
|
||||||
if (!battleInfo) return;
|
if (!battleInfo) return;
|
||||||
if (battleInfo.matchId) battleId.value = battleInfo.matchId;
|
if (battleInfo.matchId) battleId.value = battleInfo.matchId;
|
||||||
if (battleInfo.status !== undefined) {
|
if (battleInfo.status !== undefined) {
|
||||||
@@ -745,6 +841,8 @@ function applyReadyState(battleInfo) {
|
|||||||
|
|
||||||
// 快照恢复入口:只把页面拉到服务端最新状态,不重放已经发生过的语音。
|
// 快照恢复入口:只把页面拉到服务端最新状态,不重放已经发生过的语音。
|
||||||
function applyBattleSnapshot(battleInfo, { restore = false, restoreEventType = 0 } = {}) {
|
function applyBattleSnapshot(battleInfo, { restore = false, restoreEventType = 0 } = {}) {
|
||||||
|
battleInfo = normalizeBattleInfo(battleInfo);
|
||||||
|
if (!battleInfo) return;
|
||||||
// 快照恢复只负责“把页面拉回最新状态”,不重放历史语音。
|
// 快照恢复只负责“把页面拉回最新状态”,不重放历史语音。
|
||||||
applyBattleBase(battleInfo);
|
applyBattleBase(battleInfo);
|
||||||
if (battleInfo.status === 0) {
|
if (battleInfo.status === 0) {
|
||||||
@@ -752,6 +850,12 @@ function applyBattleSnapshot(battleInfo, { restore = false, restoreEventType = 0
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!hasStartedSnapshot(battleInfo)) {
|
||||||
|
hideRestoreLoading();
|
||||||
|
if (start.value === false) applyReadyState(battleInfo);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
start.value = true;
|
start.value = true;
|
||||||
showRoundTip.value = false;
|
showRoundTip.value = false;
|
||||||
|
|
||||||
@@ -793,6 +897,7 @@ async function runBattleStartTask(task, runId) {
|
|||||||
pendingRoundAudio = true;
|
pendingRoundAudio = true;
|
||||||
updateShotInfo(task.message);
|
updateShotInfo(task.message);
|
||||||
await playAudioKeys("比赛开始", { interrupt: false });
|
await playAudioKeys("比赛开始", { interrupt: false });
|
||||||
|
notifyMatchAudioAck(task);
|
||||||
if (!isQueueAlive(runId)) return;
|
if (!isQueueAlive(runId)) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -846,6 +951,7 @@ async function runToSomeoneShootTask(task, runId) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await audioPromise;
|
await audioPromise;
|
||||||
|
notifyMatchAudioAck(task);
|
||||||
if (!isQueueAlive(runId)) return;
|
if (!isQueueAlive(runId)) return;
|
||||||
|
|
||||||
const remainingSeconds = getRemainingSeconds(battleInfo, task, {
|
const remainingSeconds = getRemainingSeconds(battleInfo, task, {
|
||||||
@@ -918,6 +1024,7 @@ async function runShootResultTask(task) {
|
|||||||
const audioKeys = buildShootResultAudioKeys(battleInfo.shootData);
|
const audioKeys = buildShootResultAudioKeys(battleInfo.shootData);
|
||||||
if (isTententen) audioKeys.push("tententen");
|
if (isTententen) audioKeys.push("tententen");
|
||||||
await playAudioKeys(audioKeys, { interrupt: false });
|
await playAudioKeys(audioKeys, { interrupt: false });
|
||||||
|
notifyMatchAudioAck(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新回合任务:展示上一回合结算弹窗,弹窗关闭后再允许下一轮继续。
|
// 新回合任务:展示上一回合结算弹窗,弹窗关闭后再允许下一轮继续。
|
||||||
@@ -977,6 +1084,7 @@ async function runBattleEndTask(task, runId) {
|
|||||||
|
|
||||||
// 终局语音必须完整播完,再决定跳转或返回。
|
// 终局语音必须完整播完,再决定跳转或返回。
|
||||||
await playAudioKeys("比赛结束", { interrupt: false, timeout: AUDIO_TIMEOUT_MAX });
|
await playAudioKeys("比赛结束", { interrupt: false, timeout: AUDIO_TIMEOUT_MAX });
|
||||||
|
notifyMatchAudioAck(task);
|
||||||
if (!isQueueAlive(runId)) return;
|
if (!isQueueAlive(runId)) return;
|
||||||
|
|
||||||
if (matchStatus.value === 2) {
|
if (matchStatus.value === 2) {
|
||||||
@@ -991,13 +1099,14 @@ async function runBattleEndTask(task, runId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 无效射击任务:弹 toast 并播报无效射击,不修改比赛主状态。
|
// 无效射击任务:弹 toast 并播报无效射击,不修改比赛主状态。
|
||||||
async function runInvalidShotTask(runId) {
|
async function runInvalidShotTask(task, runId) {
|
||||||
if (!isQueueAlive(runId)) return;
|
if (!isQueueAlive(runId)) return;
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: "距离不足,无效",
|
title: "距离不足,无效",
|
||||||
icon: "none",
|
icon: "none",
|
||||||
});
|
});
|
||||||
await playAudioKeys("射击无效", { interrupt: false });
|
await playAudioKeys("射击无效", { interrupt: false });
|
||||||
|
notifyMatchAudioAck(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 回前台恢复入口:拉取服务端快照,处理结束态,然后继续消费增量队列。
|
// 回前台恢复入口:拉取服务端快照,处理结束态,然后继续消费增量队列。
|
||||||
@@ -1016,6 +1125,7 @@ async function restoreLatestBattle() {
|
|||||||
console.log("restore latest battle failed:", err);
|
console.log("restore latest battle failed:", err);
|
||||||
}
|
}
|
||||||
if (currentRestoreId !== restoreGeneration) return;
|
if (currentRestoreId !== restoreGeneration) return;
|
||||||
|
result = normalizeBattleInfo(result);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
hideRestoreLoading();
|
hideRestoreLoading();
|
||||||
runBattleQueue();
|
runBattleQueue();
|
||||||
@@ -1041,6 +1151,7 @@ async function restoreLatestBattle() {
|
|||||||
if (result.status === 4) {
|
if (result.status === 4) {
|
||||||
clearXRingStreaks();
|
clearXRingStreaks();
|
||||||
}
|
}
|
||||||
|
reconnectMatchServer(result);
|
||||||
|
|
||||||
if (restoreEventType === MESSAGETYPESV2.NewRound) {
|
if (restoreEventType === MESSAGETYPESV2.NewRound) {
|
||||||
const prevRound = getRestorePrevRound(result);
|
const prevRound = getRestorePrevRound(result);
|
||||||
@@ -1135,6 +1246,10 @@ onLoad((options) => {
|
|||||||
store.updateShotInfo(0, 0);
|
store.updateShotInfo(0, 0);
|
||||||
store.updateTips("");
|
store.updateTips("");
|
||||||
latestShotFlash.value = null;
|
latestShotFlash.value = null;
|
||||||
|
const readySnapshot = takeReadySnapshot(battleId.value);
|
||||||
|
if (readySnapshot?.status === 0) {
|
||||||
|
applyBattleSnapshot(readySnapshot, { restore: true });
|
||||||
|
}
|
||||||
scheduleRestoreLatestBattle();
|
scheduleRestoreLatestBattle();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
// 比赛数据适配器:统一接口返回和比赛服 websocket 的字段结构。
|
||||||
|
// 页面继续使用原来的 camelCase 数据,后端可以返回 snake_case 或新的 matchInfo 包装结构。
|
||||||
|
export const MATCH_STATUS_BY_TEXT = {
|
||||||
|
MATCH_STATUS_READY: 0,
|
||||||
|
MATCH_STATUS_STARTED: 1,
|
||||||
|
MATCH_STATUS_END: 2,
|
||||||
|
MATCH_STATUS_TIMEOUT: 3,
|
||||||
|
MATCH_STATUS_UNEXPECTEDLY: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function pickField(source, camelKey, snakeKey) {
|
||||||
|
return source?.[camelKey] ?? source?.[snakeKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeId(value) {
|
||||||
|
if (value === undefined || value === null || value === "") return "";
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCamelKey(key) {
|
||||||
|
return key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePlainObject(value) {
|
||||||
|
if (Array.isArray(value)) return value.map(normalizePlainObject);
|
||||||
|
if (!value || typeof value !== "object") return value;
|
||||||
|
|
||||||
|
return Object.keys(value).reduce((result, key) => {
|
||||||
|
result[toCamelKey(key)] = normalizePlainObject(value[key]);
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMapValues(map = {}) {
|
||||||
|
return Object.keys(map || {}).reduce((result, key) => {
|
||||||
|
result[key] = normalizePlainObject(map[key]);
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeShootListMap(map = {}) {
|
||||||
|
return Object.keys(map || {}).reduce((result, key) => {
|
||||||
|
const value = map[key];
|
||||||
|
const items = Array.isArray(value)
|
||||||
|
? value
|
||||||
|
: Array.isArray(value?.items)
|
||||||
|
? value.items
|
||||||
|
: [];
|
||||||
|
result[key] = items.map(normalizePlainObject);
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRound(round = {}) {
|
||||||
|
const normalized = normalizePlainObject(round);
|
||||||
|
normalized.shoots = normalizeShootListMap(round.shoots || normalized.shoots);
|
||||||
|
normalized.scores = normalizeMapValues(round.scores || normalized.scores);
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeMatchInfo(matchInfo = {}) {
|
||||||
|
if (!matchInfo || typeof matchInfo !== "object") return matchInfo;
|
||||||
|
|
||||||
|
const normalized = normalizePlainObject(matchInfo);
|
||||||
|
const statusText = normalized.statusText || matchInfo.status_text;
|
||||||
|
|
||||||
|
if (
|
||||||
|
(normalized.status === undefined ||
|
||||||
|
normalized.status === null ||
|
||||||
|
normalized.status === "") &&
|
||||||
|
MATCH_STATUS_BY_TEXT[statusText] !== undefined
|
||||||
|
) {
|
||||||
|
normalized.status = MATCH_STATUS_BY_TEXT[statusText];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchInfo.teams || normalized.teams) {
|
||||||
|
normalized.teams = normalizeMapValues(matchInfo.teams || normalized.teams);
|
||||||
|
}
|
||||||
|
if (Array.isArray(matchInfo.rounds || normalized.rounds)) {
|
||||||
|
normalized.rounds = (matchInfo.rounds || normalized.rounds).map(normalizeRound);
|
||||||
|
}
|
||||||
|
if (matchInfo.current || normalized.current) {
|
||||||
|
normalized.current = normalizePlainObject(matchInfo.current || normalized.current);
|
||||||
|
}
|
||||||
|
if (matchInfo.next || normalized.next) {
|
||||||
|
normalized.next = normalizePlainObject(matchInfo.next || normalized.next);
|
||||||
|
}
|
||||||
|
if (matchInfo.shoot_data || normalized.shootData) {
|
||||||
|
normalized.shootData = normalizePlainObject(matchInfo.shoot_data || normalized.shootData);
|
||||||
|
}
|
||||||
|
if (Array.isArray(matchInfo.result_list || normalized.resultList)) {
|
||||||
|
normalized.resultList = (matchInfo.result_list || normalized.resultList).map(
|
||||||
|
normalizePlainObject
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBattleApiResult(result) {
|
||||||
|
if (!result || typeof result !== "object") return result;
|
||||||
|
|
||||||
|
// request 通常已经解出顶层 data;这里额外兼容未解包和旧版扁平结构。
|
||||||
|
const resultData = result.data && typeof result.data === "object" ? result.data : null;
|
||||||
|
const payload =
|
||||||
|
resultData && (resultData.matchInfo || resultData.match_info)
|
||||||
|
? resultData
|
||||||
|
: result;
|
||||||
|
const matchInfo = payload.matchInfo || payload.match_info || payload;
|
||||||
|
const normalized = normalizeMatchInfo(matchInfo);
|
||||||
|
const serverAddr =
|
||||||
|
payload.serverAddr ||
|
||||||
|
payload.server_addr ||
|
||||||
|
matchInfo.serverAddr ||
|
||||||
|
matchInfo.server_addr ||
|
||||||
|
normalized.serverAddr;
|
||||||
|
|
||||||
|
if (serverAddr) normalized.serverAddr = serverAddr;
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user