1278 lines
41 KiB
Vue
1278 lines
41 KiB
Vue
<script setup>
|
||
import { ref, onMounted, onBeforeUnmount, nextTick, watch } from "vue";
|
||
import { onHide, onLoad, onShow } from "@dcloudio/uni-app";
|
||
import Container from "./components/Container.vue";
|
||
import BattleHeader from "./components/BattleHeader.vue";
|
||
import BowTarget from "./components/BowTarget.vue";
|
||
import BattleFooter from "./components/BattleFooter.vue";
|
||
import ScreenHint from "./components/ScreenHint.vue";
|
||
import SButton from "./components/SButton.vue";
|
||
import RoundEndTip from "./components/RoundEndTip.vue";
|
||
import TestDistance from "./components/TestDistance.vue";
|
||
import TeamAvatars from "./components/TeamAvatars.vue";
|
||
import ShootProgress2 from "./components/ShootProgress2.vue";
|
||
import SModal from "./components/SModal.vue";
|
||
import { laserCloseAPI, getBattleAPI } from "@/apis";
|
||
import { MESSAGETYPESV2 } from "@/constants";
|
||
import { getDirectionText } from "@/util";
|
||
import audioManager, {
|
||
AUDIO_INTERRUPTION_BEGIN_EVENT,
|
||
AUDIO_INTERRUPTION_END_EVENT,
|
||
} from "@/audioManager";
|
||
import useStore from "@/store";
|
||
import { storeToRefs } from "pinia";
|
||
|
||
const store = useStore();
|
||
const { user, online } = storeToRefs(store);
|
||
|
||
const DEFAULT_SHOOT_TIME = 15;
|
||
const READY_SECONDS = 15;
|
||
const READY_TIMER_EMIT_DELAY = 200;
|
||
const RESTORE_DELAY = 300;
|
||
const RESTORE_EMPTY_ID_RETRY_DELAY = 50;
|
||
const RESTORE_LOADING_TIMEOUT = 8000;
|
||
const ROUND_TIP_DELAY = 800;
|
||
const ROUND_TIP_WAIT_GRACE = 300;
|
||
const PROGRESS_START_DELAY = 100;
|
||
const PROGRESS_MIN_TICK_MS = 1;
|
||
const PROGRESS_ZERO_WAIT_GRACE = 120;
|
||
const AUDIO_TIMEOUT_BASE = 3500;
|
||
const AUDIO_TIMEOUT_PER_KEY = 2600;
|
||
const AUDIO_TIMEOUT_MAX = 12000;
|
||
const BATTLE_CANCEL_RETURN_DELAY = 2000;
|
||
const ROUND_AUDIO_NAMES = ["一", "二", "三", "四", "五"];
|
||
const X_RING_STREAKS_KEY = "team-battle-x-ring-streaks";
|
||
const PROGRESS_ZERO_EVENT = "team-battle-progress-zero";
|
||
const COUNTDOWN_READY_EVENT = "team-battle-countdown-ready";
|
||
|
||
// 页面级状态:负责承载比赛快照、当前轮次、倒计时、结算弹窗等展示信息。
|
||
const start = ref(null);
|
||
const tips = ref("");
|
||
const battleId = ref("");
|
||
const currentRound = ref(0);
|
||
const roundTipRound = ref(0);
|
||
const goldenRound = ref(0);
|
||
const currentRedPoint = ref(0);
|
||
const currentBluePoint = ref(0);
|
||
const scores = ref([]);
|
||
const blueScores = ref([]);
|
||
const redTeam = ref([]);
|
||
const blueTeam = ref([]);
|
||
const currentShooterId = ref(0);
|
||
const roundResults = ref([]);
|
||
const redPoints = ref(0);
|
||
const bluePoints = ref(0);
|
||
const latestShotFlash = ref(null);
|
||
const showRoundTip = ref(false);
|
||
const isFinalShoot = ref(false);
|
||
const matchStatus = ref(undefined);
|
||
const shootTimeTotal = ref(DEFAULT_SHOOT_TIME);
|
||
const showOfflineModal = ref(false);
|
||
const restoreLoading = ref(false);
|
||
const xRingStreaks = ref({});
|
||
|
||
// 消息队列:只保存“待执行”的战况消息,页面展示统一由队列串行驱动。
|
||
const battleQueue = ref([]);
|
||
const queueRunning = ref(false);
|
||
// latestSnapshotServerTime:回前台拉到的最新快照时间。
|
||
const latestSnapshotServerTime = ref(0);
|
||
// latestAppliedServerTime:已经真正应用到页面上的最新消息时间。
|
||
const latestAppliedServerTime = ref(0);
|
||
|
||
// 队列代际:页面重置、离开或恢复快照时递增,让旧任务自动失效。
|
||
let queueGeneration = 0;
|
||
let queueOrder = 0;
|
||
let restoreGeneration = 0;
|
||
let pendingRestoreTimer = null;
|
||
let restoreLoadingTimer = null;
|
||
// 轮次提示音和普通射击音分开处理,避免新回合提示被旧状态打断。
|
||
let pendingRoundAudio = false;
|
||
// 一旦收到 BattleEnd,后续普通消息就不再进入队列。
|
||
let battleEnded = false;
|
||
const handledMessageKeys = new Set();
|
||
const handledMessageKeyOrder = [];
|
||
const queuedMessageKeys = new Set();
|
||
// 每个语音任务都挂一个等待器,既等 audioEnded,也等超时兜底。
|
||
const audioWaiters = new Set();
|
||
// 记录倒计时归零的时刻,用来卡住下一位射手的切换时机。
|
||
let progressDeadlineAt = 0;
|
||
const progressZeroWaiters = new Set();
|
||
// 回合结算弹窗也纳入队列节奏,确保上一轮看完再进下一轮。
|
||
const roundTipWaiters = new Set();
|
||
|
||
// 监听设备在线状态:比赛进行中掉线时展示提示弹窗。
|
||
watch(online, (newVal, oldVal) => {
|
||
if (!newVal && oldVal && start.value === true) {
|
||
showOfflineModal.value = true;
|
||
}
|
||
});
|
||
|
||
// 统一把秒级或毫秒级时间戳转成毫秒,方便和本机时间比较。
|
||
function loadXRingStreaks() {
|
||
const cached = uni.getStorageSync(X_RING_STREAKS_KEY);
|
||
xRingStreaks.value =
|
||
cached && typeof cached === "object" && !Array.isArray(cached) ? cached : {};
|
||
}
|
||
|
||
function saveXRingStreaks() {
|
||
uni.setStorageSync(X_RING_STREAKS_KEY, xRingStreaks.value);
|
||
}
|
||
|
||
function clearXRingStreaks() {
|
||
xRingStreaks.value = {};
|
||
uni.removeStorageSync(X_RING_STREAKS_KEY);
|
||
}
|
||
|
||
function normalizeTimestamp(value) {
|
||
const numberValue = Number(value || 0);
|
||
if (!numberValue) return 0;
|
||
return numberValue < 1000000000000 ? numberValue * 1000 : numberValue;
|
||
}
|
||
|
||
// 从不同消息结构中提取服务端时间,作为恢复和去重的时间基准。
|
||
function getServerTime(message) {
|
||
return normalizeTimestamp(
|
||
message?.serverTime ?? message?.current?.serverTime ?? message?.shootData?.serverTime
|
||
);
|
||
}
|
||
|
||
function getMessageKey(message) {
|
||
// 优先使用 serverTime 作为稳定去重主键;没有 serverTime 时再用业务字段拼降级 key。
|
||
const serverTime = getServerTime(message);
|
||
if (serverTime) return `${message.type}:${serverTime}`;
|
||
|
||
const current = message?.current || {};
|
||
const shootData = message?.shootData || {};
|
||
return [
|
||
message?.type ?? "",
|
||
current.round ?? "",
|
||
current.playerId ?? shootData.playerId ?? "",
|
||
normalizeTimestamp(current.startTime),
|
||
message?.status ?? "",
|
||
shootData.id ?? shootData.shootId ?? shootData.arrowId ?? "",
|
||
shootData.ring ?? "",
|
||
shootData.ringX ? 1 : 0,
|
||
shootData.x ?? "",
|
||
shootData.y ?? "",
|
||
shootData.angle ?? "",
|
||
].join(":");
|
||
}
|
||
|
||
function rememberHandledMessageKey(key) {
|
||
if (!key || handledMessageKeys.has(key)) return;
|
||
handledMessageKeys.add(key);
|
||
handledMessageKeyOrder.push(key);
|
||
if (handledMessageKeyOrder.length > 500) {
|
||
const oldKey = handledMessageKeyOrder.shift();
|
||
handledMessageKeys.delete(oldKey);
|
||
}
|
||
}
|
||
|
||
function getMessagePriority(type) {
|
||
if (type === MESSAGETYPESV2.BattleEnd) return 100;
|
||
if (type === MESSAGETYPESV2.BattleStart) return 10;
|
||
if (type === MESSAGETYPESV2.ShootResult) return 20;
|
||
if (type === MESSAGETYPESV2.NewRound) return 30;
|
||
if (type === MESSAGETYPESV2.ToSomeoneShoot) return 40;
|
||
if (type === MESSAGETYPESV2.InvalidShot) return 50;
|
||
return 60;
|
||
}
|
||
|
||
// 队列排序:终局优先,其次按服务端时间,最后按消息类型优先级和入队顺序兜底。
|
||
function sortBattleQueue() {
|
||
battleQueue.value.sort((a, b) => {
|
||
if (a.type === MESSAGETYPESV2.BattleEnd && b.type !== MESSAGETYPESV2.BattleEnd) return -1;
|
||
if (b.type === MESSAGETYPESV2.BattleEnd && a.type !== MESSAGETYPESV2.BattleEnd) return 1;
|
||
if (a.serverTime && b.serverTime && a.serverTime !== b.serverTime) {
|
||
return a.serverTime - b.serverTime;
|
||
}
|
||
if (a.serverTime && !b.serverTime) return -1;
|
||
if (!a.serverTime && b.serverTime) return 1;
|
||
const priorityDiff = getMessagePriority(a.type) - getMessagePriority(b.type);
|
||
return priorityDiff || a.order - b.order;
|
||
});
|
||
}
|
||
|
||
function isQueueAlive(runId) {
|
||
return runId === queueGeneration;
|
||
}
|
||
|
||
function clearAudioWaiters() {
|
||
Array.from(audioWaiters).forEach((waiter) => waiter.done());
|
||
}
|
||
|
||
function clearProgressZeroWaiters() {
|
||
Array.from(progressZeroWaiters).forEach((waiter) => waiter.done());
|
||
}
|
||
|
||
function clearRoundTipWaiters() {
|
||
Array.from(roundTipWaiters).forEach((waiter) => waiter.done());
|
||
}
|
||
|
||
function waitForRoundTipClosed(isFinal) {
|
||
const autoCloseSeconds = isFinal ? 11 : 4;
|
||
const waitMs = autoCloseSeconds * 1000 + ROUND_TIP_WAIT_GRACE;
|
||
|
||
return new Promise((resolve) => {
|
||
let settled = false;
|
||
const waiter = {
|
||
done: () => {
|
||
if (settled) return;
|
||
settled = true;
|
||
clearTimeout(timer);
|
||
roundTipWaiters.delete(waiter);
|
||
resolve();
|
||
},
|
||
};
|
||
const timer = setTimeout(waiter.done, waitMs);
|
||
roundTipWaiters.add(waiter);
|
||
});
|
||
}
|
||
|
||
function closeRoundTip() {
|
||
showRoundTip.value = false;
|
||
clearRoundTipWaiters();
|
||
}
|
||
|
||
function cancelRoundTipDisplay() {
|
||
closeRoundTip();
|
||
}
|
||
|
||
function handleRoundTipAutoClose() {
|
||
closeRoundTip();
|
||
}
|
||
|
||
function markProgressDeadline(countdown, delayMs = 0) {
|
||
if (!countdown?.value || !countdown?.durationMs) {
|
||
progressDeadlineAt = 0;
|
||
clearProgressZeroWaiters();
|
||
return;
|
||
}
|
||
progressDeadlineAt = Date.now() + delayMs + countdown.durationMs;
|
||
}
|
||
|
||
function waitForProgressZero() {
|
||
const waitMs = Math.ceil(progressDeadlineAt - Date.now());
|
||
if (waitMs <= 0) return Promise.resolve();
|
||
|
||
return new Promise((resolve) => {
|
||
let settled = false;
|
||
const waiter = {
|
||
done: () => {
|
||
if (settled) return;
|
||
settled = true;
|
||
clearTimeout(timer);
|
||
progressZeroWaiters.delete(waiter);
|
||
resolve();
|
||
},
|
||
};
|
||
const timer = setTimeout(waiter.done, waitMs + PROGRESS_ZERO_WAIT_GRACE);
|
||
progressZeroWaiters.add(waiter);
|
||
});
|
||
}
|
||
|
||
function onProgressZero() {
|
||
progressDeadlineAt = 0;
|
||
clearProgressZeroWaiters();
|
||
}
|
||
|
||
function clearRestoreLoadingTimer() {
|
||
if (restoreLoadingTimer) {
|
||
clearTimeout(restoreLoadingTimer);
|
||
restoreLoadingTimer = null;
|
||
}
|
||
}
|
||
|
||
function hideRestoreLoading() {
|
||
restoreLoading.value = false;
|
||
clearRestoreLoadingTimer();
|
||
}
|
||
|
||
function showRestoreLoading() {
|
||
clearRestoreLoadingTimer();
|
||
restoreLoading.value = true;
|
||
restoreLoadingTimer = setTimeout(() => {
|
||
restoreLoadingTimer = null;
|
||
restoreLoading.value = false;
|
||
}, RESTORE_LOADING_TIMEOUT);
|
||
}
|
||
|
||
// 页面重置、离开或恢复快照时调用,统一清理队列、等待器、音频和进度条。
|
||
function invalidateBattleQueue({ stopAudio = false, stopProgress = false } = {}) {
|
||
// 提升队列代际并清空待执行消息,确保恢复快照时不会继续跑旧战况。
|
||
queueGeneration += 1;
|
||
battleQueue.value = [];
|
||
queueRunning.value = false;
|
||
queuedMessageKeys.clear();
|
||
clearAudioWaiters();
|
||
progressDeadlineAt = 0;
|
||
clearProgressZeroWaiters();
|
||
cancelRoundTipDisplay();
|
||
if (stopAudio) audioManager.stopAll();
|
||
if (stopProgress) uni.$emit("update-remain", { stop: true });
|
||
}
|
||
|
||
// socket 消息入口:先做去重和时间边界判断,再放入队列等待串行执行。
|
||
function enqueueBattleMessage(message) {
|
||
if (Array.isArray(message) || !message?.type) return;
|
||
if (battleEnded && message.type !== MESSAGETYPESV2.BattleEnd) return;
|
||
if (message.type === MESSAGETYPESV2.BattleEnd) battleEnded = true;
|
||
|
||
// 入队阶段只做排序、去重和时间边界判断,不直接改 UI。
|
||
const serverTime = getServerTime(message);
|
||
const key = getMessageKey(message);
|
||
if (!key || handledMessageKeys.has(key) || queuedMessageKeys.has(key)) return;
|
||
if (serverTime && latestSnapshotServerTime.value && serverTime <= latestSnapshotServerTime.value) return;
|
||
if (serverTime && latestAppliedServerTime.value && serverTime < latestAppliedServerTime.value) return;
|
||
queuedMessageKeys.add(key);
|
||
|
||
if (message.type === MESSAGETYPESV2.BattleEnd) {
|
||
battleQueue.value = battleQueue.value.filter(
|
||
(task) => task.type === MESSAGETYPESV2.BattleEnd
|
||
);
|
||
}
|
||
|
||
battleQueue.value.push({
|
||
message,
|
||
type: message.type,
|
||
key,
|
||
serverTime,
|
||
receivedAt: Date.now(),
|
||
order: ++queueOrder,
|
||
});
|
||
sortBattleQueue();
|
||
runBattleQueue();
|
||
}
|
||
|
||
// 队列消费入口:同一时间只允许一个消费者执行,保证 UI 和语音顺序稳定。
|
||
async function runBattleQueue() {
|
||
if (queueRunning.value) return;
|
||
queueRunning.value = true;
|
||
const runId = queueGeneration;
|
||
|
||
// 串行消费队列,保证“播音 -> 展示 -> 倒计时”顺序稳定。
|
||
while (battleQueue.value.length && isQueueAlive(runId)) {
|
||
const task = battleQueue.value.shift();
|
||
try {
|
||
if (task?.serverTime) {
|
||
latestAppliedServerTime.value = Math.max(latestAppliedServerTime.value, task.serverTime);
|
||
}
|
||
await executeBattleTask(task, runId);
|
||
queuedMessageKeys.delete(task?.key);
|
||
rememberHandledMessageKey(task?.key);
|
||
} catch (err) {
|
||
queuedMessageKeys.delete(task?.key);
|
||
rememberHandledMessageKey(task?.key);
|
||
console.log("team battle queue task error:", err);
|
||
}
|
||
}
|
||
|
||
if (isQueueAlive(runId)) queueRunning.value = false;
|
||
}
|
||
|
||
// 根据消息类型分发到具体任务处理器,所有任务都要尊重当前队列代际。
|
||
async function executeBattleTask(task, runId) {
|
||
if (!task || !isQueueAlive(runId)) return;
|
||
const type = task.type;
|
||
|
||
if (type === MESSAGETYPESV2.BattleStart) {
|
||
await runBattleStartTask(task, runId);
|
||
} else if (type === MESSAGETYPESV2.ToSomeoneShoot) {
|
||
await runToSomeoneShootTask(task, runId);
|
||
} else if (type === MESSAGETYPESV2.ShootResult) {
|
||
await runShootResultTask(task, runId);
|
||
} else if (type === MESSAGETYPESV2.NewRound) {
|
||
await runNewRoundTask(task, runId);
|
||
} else if (type === MESSAGETYPESV2.BattleEnd) {
|
||
await runBattleEndTask(task, runId);
|
||
} else if (type === MESSAGETYPESV2.InvalidShot) {
|
||
await runInvalidShotTask(runId);
|
||
}
|
||
}
|
||
|
||
// 播放一组语音并等待完成;带超时兜底,避免 audioEnded 丢失导致队列卡死。
|
||
function playAudioKeys(keys, { interrupt = false, timeout } = {}) {
|
||
const audioKeys = (Array.isArray(keys) ? keys : [keys]).filter(Boolean);
|
||
if (!audioKeys.length) return Promise.resolve();
|
||
|
||
// 语音任务必须有超时兜底,避免 audioEnded 丢失导致队列卡死。
|
||
const expectedKey = audioKeys[audioKeys.length - 1];
|
||
const waitTime =
|
||
timeout ??
|
||
Math.min(
|
||
AUDIO_TIMEOUT_MAX,
|
||
Math.max(AUDIO_TIMEOUT_BASE, audioKeys.length * AUDIO_TIMEOUT_PER_KEY)
|
||
);
|
||
|
||
return new Promise((resolve) => {
|
||
let settled = false;
|
||
const waiter = {
|
||
expectedKey,
|
||
done: () => {
|
||
if (settled) return;
|
||
settled = true;
|
||
clearTimeout(timer);
|
||
audioWaiters.delete(waiter);
|
||
resolve();
|
||
},
|
||
};
|
||
const timer = setTimeout(waiter.done, waitTime);
|
||
audioWaiters.add(waiter);
|
||
audioManager.play(audioKeys, interrupt);
|
||
});
|
||
}
|
||
|
||
function onAudioEnded(key) {
|
||
// 某个语音真正播完后,唤醒对应等待器,继续执行后续队列。
|
||
Array.from(audioWaiters).forEach((waiter) => {
|
||
if (waiter.expectedKey === key) waiter.done();
|
||
});
|
||
}
|
||
|
||
function handleBattleCovered() {
|
||
if (pendingRestoreTimer) {
|
||
clearTimeout(pendingRestoreTimer);
|
||
pendingRestoreTimer = null;
|
||
}
|
||
hideRestoreLoading();
|
||
pendingRoundAudio = false;
|
||
invalidateBattleQueue({ stopAudio: true, stopProgress: true });
|
||
}
|
||
|
||
function handleBattleRecovered() {
|
||
scheduleRestoreLatestBattle();
|
||
}
|
||
|
||
// 队伍信息优先用接口返回值;接口缺失时使用本地缓存,避免重进页面时头像为空。
|
||
function loadTeamPlayers(teamInfo, storageKey) {
|
||
if (Array.isArray(teamInfo?.players)) return [...teamInfo.players];
|
||
const cached = uni.getStorageSync(storageKey);
|
||
return Array.isArray(cached) ? [...cached] : [];
|
||
}
|
||
|
||
function updateTeams(battleInfo) {
|
||
const teams = battleInfo?.teams || {};
|
||
blueTeam.value = loadTeamPlayers(teams[1], "blue-team");
|
||
redTeam.value = loadTeamPlayers(teams[2], "red-team");
|
||
}
|
||
|
||
function updateGoldenRound(battleInfo) {
|
||
if (!battleInfo?.current?.goldRound) {
|
||
goldenRound.value = 0;
|
||
return;
|
||
}
|
||
const rounds = Array.isArray(battleInfo.rounds) ? battleInfo.rounds : [];
|
||
const finishedGoldCount = rounds.filter((round) => !!round?.ifGold).length;
|
||
goldenRound.value = Math.max(1, finishedGoldCount + (battleInfo.current?.playerId ? 1 : 0));
|
||
}
|
||
|
||
// Restore an info snapshot whose eventType points at the NewRound phase.
|
||
function getRestorePrevRound(battleInfo) {
|
||
const currentRoundValue = Number(battleInfo?.current?.round || 0);
|
||
if (currentRoundValue > 1) return currentRoundValue - 1;
|
||
const rounds = Array.isArray(battleInfo?.rounds) ? battleInfo.rounds : [];
|
||
return Math.max(1, rounds.length || 1);
|
||
}
|
||
|
||
function applyRestoreNewRoundSnapshot(battleInfo) {
|
||
const prevRound = getRestorePrevRound(battleInfo);
|
||
start.value = true;
|
||
showRoundTip.value = false;
|
||
scores.value = [];
|
||
blueScores.value = [];
|
||
currentRound.value = prevRound;
|
||
|
||
if (battleInfo.current?.goldRound) {
|
||
store.updateShotInfo(0, 0);
|
||
currentBluePoint.value = battleInfo.teams?.[1]?.score ?? 0;
|
||
currentRedPoint.value = battleInfo.teams?.[2]?.score ?? 0;
|
||
} else {
|
||
const latestRound = battleInfo.rounds?.[prevRound - 1];
|
||
if (battleInfo?.shootNumber) {
|
||
store.updateShotInfo(0, battleInfo.shootNumber);
|
||
}
|
||
|
||
if (latestRound) {
|
||
currentBluePoint.value = latestRound.scores?.[1]?.score ?? 0;
|
||
currentRedPoint.value = latestRound.scores?.[2]?.score ?? 0;
|
||
} else {
|
||
currentBluePoint.value = 0;
|
||
currentRedPoint.value = 0;
|
||
}
|
||
}
|
||
|
||
return prevRound;
|
||
}
|
||
|
||
// 回填比赛基础信息:队伍、比分、轮次、金箭状态等公共字段都在这里统一处理。
|
||
function applyBattleBase(battleInfo) {
|
||
if (!battleInfo) return;
|
||
if (battleInfo.matchId) battleId.value = battleInfo.matchId;
|
||
if (battleInfo.status !== undefined) {
|
||
start.value = battleInfo.status !== 0;
|
||
matchStatus.value = battleInfo.status;
|
||
if (battleInfo.status !== 2 && battleInfo.status !== 4) {
|
||
battleEnded = false;
|
||
}
|
||
}
|
||
updateTeams(battleInfo);
|
||
shootTimeTotal.value = getShootTimeSeconds(battleInfo);
|
||
roundResults.value = Array.isArray(battleInfo.rounds) ? battleInfo.rounds : [];
|
||
bluePoints.value = battleInfo.teams?.[1]?.score ?? bluePoints.value;
|
||
redPoints.value = battleInfo.teams?.[2]?.score ?? redPoints.value;
|
||
isFinalShoot.value = !!battleInfo.current?.goldRound;
|
||
updateGoldenRound(battleInfo);
|
||
}
|
||
|
||
// 同步顶部射箭进度到全局 store,供 HeaderProgress 在重进页面时直接恢复显示。
|
||
function updateShotInfo(battleInfo) {
|
||
if (battleInfo?.status === 0 || battleInfo?.current?.goldRound) {
|
||
store.updateShotInfo(0, 0);
|
||
return;
|
||
}
|
||
const totalShot = Number(battleInfo?.shootNumber || 0);
|
||
if (!totalShot) return;
|
||
|
||
const indexMap = battleInfo?.current?.indexMap || {};
|
||
const userId = user.value.id;
|
||
const myShot = indexMap[userId] ?? indexMap[String(userId)];
|
||
store.updateShotInfo(myShot ?? 0, totalShot);
|
||
}
|
||
|
||
// 从当前轮数据中拆出红蓝两队箭点,驱动靶面重新渲染。
|
||
function updateCurrentRoundScores(battleInfo) {
|
||
const round = battleInfo?.current?.round || currentRound.value;
|
||
const latestRound = battleInfo?.rounds?.[round - 1];
|
||
if (!latestRound) {
|
||
scores.value = [];
|
||
blueScores.value = [];
|
||
return;
|
||
}
|
||
blueScores.value = latestRound.shoots?.[1] || [];
|
||
scores.value = latestRound.shoots?.[2] || [];
|
||
}
|
||
|
||
function resolveTargetTeam(battleInfo) {
|
||
const playerId = battleInfo?.current?.playerId;
|
||
const redPlayers = battleInfo?.teams?.[2]?.players || redTeam.value;
|
||
const isRed = redPlayers.some((item) => String(item.id) === String(playerId));
|
||
return isRed ? "red" : "blue";
|
||
}
|
||
|
||
function resolveShotTeam(battleInfo) {
|
||
const playerId =
|
||
battleInfo?.shootData?.playerId ?? battleInfo?.current?.playerId ?? currentShooterId.value;
|
||
const redPlayers = battleInfo?.teams?.[2]?.players || redTeam.value;
|
||
const isRed = redPlayers.some((item) => String(item.id) === String(playerId));
|
||
return isRed ? "red" : "blue";
|
||
}
|
||
|
||
// 生成页面和顶部进度条展示的射手提示文案。
|
||
function buildShooterTips(battleInfo, team) {
|
||
let nextTips = team === "red" ? "请红队射箭" : "请蓝队射箭";
|
||
if (String(battleInfo?.current?.playerId) === String(user.value.id)) {
|
||
nextTips += "你";
|
||
}
|
||
return nextTips;
|
||
}
|
||
|
||
function updateTips(nextTips) {
|
||
tips.value = nextTips;
|
||
store.updateTips(nextTips);
|
||
uni.$emit("update-tips", nextTips);
|
||
}
|
||
|
||
function getShooterAudioKey(battleInfo, team) {
|
||
if (String(battleInfo?.current?.playerId) === String(user.value.id)) return "轮到你了";
|
||
return team === "red" ? "请红方射箭" : "请蓝方射箭";
|
||
}
|
||
|
||
function getRoundAudioKey(battleInfo) {
|
||
if (battleInfo?.current?.goldRound) return "决金箭轮";
|
||
const round = battleInfo?.current?.round || currentRound.value || 1;
|
||
return `第${ROUND_AUDIO_NAMES[Math.max(0, round - 1)] || round}轮`;
|
||
}
|
||
|
||
function getShootTimeSeconds(battleInfo) {
|
||
const total = Number(battleInfo?.shootTime ?? shootTimeTotal.value ?? DEFAULT_SHOOT_TIME);
|
||
if (!Number.isFinite(total) || total <= 0) return DEFAULT_SHOOT_TIME;
|
||
return total;
|
||
}
|
||
|
||
function normalizeRemainSeconds(value, total) {
|
||
const numberValue = Number(value);
|
||
if (!Number.isFinite(numberValue)) return null;
|
||
if (numberValue <= 0) return 0;
|
||
// 兼容后端返回毫秒的剩余时间字段,比如 8000 表示 8 秒。
|
||
const seconds = numberValue > total + 1 ? numberValue / 1000 : numberValue;
|
||
return Math.max(0, Math.min(total, seconds));
|
||
}
|
||
|
||
// 读取后端可能返回的各种剩余时间字段,兼容秒和毫秒两种格式。
|
||
function getBackendRemainingSeconds(battleInfo, total) {
|
||
const current = battleInfo?.current || {};
|
||
const candidates = [
|
||
current.remainingSeconds,
|
||
current.remainSeconds,
|
||
current.remainingSecond,
|
||
current.remainSecond,
|
||
current.remainingTime,
|
||
current.remainTime,
|
||
current.leftSeconds,
|
||
current.leftTime,
|
||
current.timeLeft,
|
||
current.countdown,
|
||
current.countDown,
|
||
battleInfo?.remainingSeconds,
|
||
battleInfo?.remainSeconds,
|
||
battleInfo?.remainingTime,
|
||
battleInfo?.remainTime,
|
||
];
|
||
|
||
for (const item of candidates) {
|
||
const remain = normalizeRemainSeconds(item, total);
|
||
if (remain !== null) return remain;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 计算当前倒计时剩余秒数:后端字段优先,其次按开始时间和当前时间补算。
|
||
function getRemainingSeconds(battleInfo, task, options = {}) {
|
||
const total = getShootTimeSeconds(battleInfo);
|
||
const backendRemain = getBackendRemainingSeconds(battleInfo, total);
|
||
if (backendRemain !== null) return backendRemain;
|
||
// 新轮首箭由后端 shootTime 定完整时长;没有明确剩余时间时,不扣前端轮次弹窗/语音耗时。
|
||
if (options.fullDurationIfNoBackendRemain) return total;
|
||
|
||
const startTime = normalizeTimestamp(battleInfo?.current?.startTime);
|
||
if (!startTime) return total;
|
||
|
||
// 后端时间是准的,前端只根据语音耗时和接收延迟做近似补偿。
|
||
// 但回前台的 API 快照不一定带“当前后端时间”,恢复场景优先用本机当前时间计算最新剩余秒数。
|
||
const anchorTime = task?.preferClientNow
|
||
? Date.now()
|
||
: task?.serverTime || getServerTime(battleInfo) || Date.now();
|
||
const waitAfterReceive = task?.serverTime ? Date.now() - task.receivedAt : 0;
|
||
const effectiveNow = anchorTime + waitAfterReceive;
|
||
const elapsed = Math.max(0, (effectiveNow - startTime) / 1000);
|
||
return Math.max(0, Math.min(total, total - elapsed));
|
||
}
|
||
|
||
// 将真实剩余时间转换成进度条组件需要的显示秒数、tick 间隔和总时长。
|
||
function getCountdownPayload(remainingSeconds, displaySeconds = remainingSeconds) {
|
||
const remain = Math.max(0, Number(remainingSeconds) || 0);
|
||
if (remain <= 0) return { value: 0, tickMs: 1000, durationMs: 0 };
|
||
|
||
// displaySeconds 控制展示出来的秒数,remainingSeconds 控制真实要跑多久。
|
||
// 新一轮射箭时按 shootTime 展示,再把真实剩余时间平均摊到每一秒上。
|
||
const showSeconds = Math.max(1, Math.ceil(Number(displaySeconds) || remain));
|
||
const tickMs = Math.max(PROGRESS_MIN_TICK_MS, Math.round((remain * 1000) / showSeconds));
|
||
return { value: showSeconds, tickMs, durationMs: tickMs * showSeconds };
|
||
}
|
||
|
||
// 重进比赛或切换射手时,先重置进度条,再启动新的倒计时。
|
||
function resetAndStartProgress(battleInfo, task, team) {
|
||
const remainingSeconds = getRemainingSeconds(battleInfo, task);
|
||
const countdown = getCountdownPayload(remainingSeconds);
|
||
markProgressDeadline(countdown, PROGRESS_START_DELAY);
|
||
|
||
nextTick(() => {
|
||
uni.$emit("update-remain", { reset: true, value: countdown.value, team });
|
||
setTimeout(() => {
|
||
uni.$emit("update-remain", {
|
||
stop: false,
|
||
value: countdown.value,
|
||
tickMs: countdown.tickMs,
|
||
team,
|
||
});
|
||
}, PROGRESS_START_DELAY);
|
||
});
|
||
}
|
||
|
||
// 没有当前射手时,停止进度条,避免页面继续跑旧的倒计时。
|
||
function stopProgressAfterMount() {
|
||
progressDeadlineAt = 0;
|
||
clearProgressZeroWaiters();
|
||
nextTick(() => {
|
||
setTimeout(() => {
|
||
uni.$emit("update-remain", { stop: true });
|
||
}, PROGRESS_START_DELAY);
|
||
});
|
||
}
|
||
|
||
// 待开局状态:清理比赛态展示,并恢复准备倒计时。
|
||
function applyReadyState(battleInfo) {
|
||
hideRestoreLoading();
|
||
start.value = false;
|
||
showRoundTip.value = false;
|
||
currentShooterId.value = 0;
|
||
scores.value = [];
|
||
blueScores.value = [];
|
||
store.updateShotInfo(0, 0);
|
||
updateTips("");
|
||
progressDeadlineAt = 0;
|
||
clearProgressZeroWaiters();
|
||
cancelRoundTipDisplay();
|
||
|
||
const createTime = normalizeTimestamp(battleInfo?.createTime || Date.now());
|
||
const readyElapsed = (Date.now() - createTime) / 1000;
|
||
if (readyElapsed > 0 && readyElapsed < READY_SECONDS) {
|
||
setTimeout(() => {
|
||
uni.$emit("update-timer", READY_SECONDS - readyElapsed - 0.2);
|
||
}, READY_TIMER_EMIT_DELAY);
|
||
}
|
||
}
|
||
|
||
// 快照恢复入口:只把页面拉到服务端最新状态,不重放已经发生过的语音。
|
||
function applyBattleSnapshot(battleInfo, { restore = false, restoreEventType = 0 } = {}) {
|
||
// 快照恢复只负责“把页面拉回最新状态”,不重放历史语音。
|
||
applyBattleBase(battleInfo);
|
||
if (battleInfo.status === 0) {
|
||
applyReadyState(battleInfo);
|
||
return;
|
||
}
|
||
|
||
start.value = true;
|
||
showRoundTip.value = false;
|
||
|
||
if (restore && restoreEventType === MESSAGETYPESV2.NewRound) {
|
||
applyRestoreNewRoundSnapshot(battleInfo);
|
||
return;
|
||
}
|
||
|
||
updateShotInfo(battleInfo);
|
||
updateCurrentRoundScores(battleInfo);
|
||
|
||
const current = battleInfo.current || {};
|
||
currentRound.value = current.round || currentRound.value || 1;
|
||
currentShooterId.value = current.playerId || 0;
|
||
|
||
if (!current.playerId) {
|
||
stopProgressAfterMount();
|
||
return;
|
||
}
|
||
|
||
const targetTeam = resolveTargetTeam(battleInfo);
|
||
updateTips(buildShooterTips(battleInfo, targetTeam));
|
||
|
||
if (restore) {
|
||
resetAndStartProgress(
|
||
battleInfo,
|
||
{ preferClientNow: true, receivedAt: Date.now() },
|
||
targetTeam
|
||
);
|
||
}
|
||
}
|
||
|
||
// 开局任务:切换到正式比赛态,并播报“比赛开始”。
|
||
async function runBattleStartTask(task, runId) {
|
||
// 开赛任务只负责切换正式态并播“比赛开始”,后续进入队列顺序。
|
||
clearXRingStreaks();
|
||
applyBattleBase(task.message);
|
||
start.value = true;
|
||
pendingRoundAudio = true;
|
||
updateShotInfo(task.message);
|
||
await playAudioKeys("比赛开始", { interrupt: false });
|
||
if (!isQueueAlive(runId)) return;
|
||
}
|
||
|
||
// 新射手任务:切换当前射手、播报提示,并启动这一箭的倒计时。
|
||
async function runToSomeoneShootTask(task, runId) {
|
||
// 新射手任务:先等上一轮倒计时归零,再更新展示、播轮次/射手语音、启动倒计时。
|
||
const battleInfo = task.message;
|
||
const shouldEnterImmediately = showRoundTip.value;
|
||
if (shouldEnterImmediately) {
|
||
cancelRoundTipDisplay();
|
||
progressDeadlineAt = 0;
|
||
clearProgressZeroWaiters();
|
||
} else {
|
||
await waitForProgressZero();
|
||
}
|
||
if (!isQueueAlive(runId)) return;
|
||
|
||
applyBattleBase(battleInfo);
|
||
start.value = true;
|
||
hideRestoreLoading();
|
||
cancelRoundTipDisplay();
|
||
updateShotInfo(battleInfo);
|
||
|
||
const current = battleInfo.current || {};
|
||
currentRound.value = current.round || currentRound.value || 1;
|
||
currentShooterId.value = current.playerId || 0;
|
||
if (!current.playerId) {
|
||
stopProgressAfterMount();
|
||
return;
|
||
}
|
||
|
||
const targetTeam = resolveTargetTeam(battleInfo);
|
||
const nextTips = buildShooterTips(battleInfo, targetTeam);
|
||
updateTips(nextTips);
|
||
|
||
const audioKeys = [];
|
||
const isRoundFirstShoot = pendingRoundAudio;
|
||
if (isRoundFirstShoot) {
|
||
audioKeys.push(getRoundAudioKey(battleInfo));
|
||
pendingRoundAudio = false;
|
||
}
|
||
audioKeys.push(getShooterAudioKey(battleInfo, targetTeam));
|
||
|
||
const audioPromise = playAudioKeys(audioKeys, { interrupt: false });
|
||
nextTick(() => {
|
||
uni.$emit("update-remain", {
|
||
zeroThenReset: true,
|
||
value: shootTimeTotal.value,
|
||
team: targetTeam,
|
||
});
|
||
});
|
||
|
||
await audioPromise;
|
||
if (!isQueueAlive(runId)) return;
|
||
|
||
const remainingSeconds = getRemainingSeconds(battleInfo, task, {
|
||
fullDurationIfNoBackendRemain: isRoundFirstShoot,
|
||
});
|
||
const countdown = getCountdownPayload(remainingSeconds, shootTimeTotal.value);
|
||
markProgressDeadline(countdown);
|
||
uni.$emit("update-remain", {
|
||
stop: false,
|
||
value: countdown.value,
|
||
tickMs: countdown.tickMs,
|
||
team: targetTeam,
|
||
});
|
||
}
|
||
|
||
function updateXRingStreak(shooterId, isXRing) {
|
||
if (!shooterId) return false;
|
||
const id = String(shooterId);
|
||
if (!isXRing) {
|
||
xRingStreaks.value[id] = 0;
|
||
saveXRingStreaks();
|
||
return false;
|
||
}
|
||
|
||
xRingStreaks.value[id] = (xRingStreaks.value[id] || 0) + 1;
|
||
if (xRingStreaks.value[id] < 3) {
|
||
saveXRingStreaks();
|
||
return false;
|
||
}
|
||
xRingStreaks.value[id] = 0;
|
||
saveXRingStreaks();
|
||
return true;
|
||
}
|
||
|
||
function buildShootResultAudioKeys(shootData) {
|
||
if (!shootData) return [];
|
||
const audioKeys = [
|
||
shootData.ring ? `${shootData.ringX ? "X" : shootData.ring}环` : "未上靶",
|
||
];
|
||
if (shootData.angle !== null && shootData.angle !== undefined) {
|
||
audioKeys.push(`向${getDirectionText(shootData.angle)}调整`);
|
||
}
|
||
return audioKeys;
|
||
}
|
||
|
||
// 报靶任务:更新箭点、比分和报靶语音,不主动切换轮次。
|
||
async function runShootResultTask(task) {
|
||
// 报靶任务只更新箭靶、分数和语音,不抢轮次提示的时序。
|
||
const battleInfo = task.message;
|
||
showRoundTip.value = false;
|
||
applyBattleBase(battleInfo);
|
||
updateShotInfo(battleInfo);
|
||
updateCurrentRoundScores(battleInfo);
|
||
if (battleInfo?.shootData) {
|
||
latestShotFlash.value = {
|
||
key: task?.key || `${battleInfo.shootData.playerId || ""}-${task?.serverTime || Date.now()}`,
|
||
team: resolveShotTeam(battleInfo),
|
||
shootData: battleInfo.shootData,
|
||
};
|
||
}
|
||
|
||
const isTententen = updateXRingStreak(
|
||
currentShooterId.value,
|
||
!!(battleInfo.shootData?.ringX && battleInfo.shootData?.ring)
|
||
);
|
||
const audioKeys = buildShootResultAudioKeys(battleInfo.shootData);
|
||
if (isTententen) audioKeys.push("tententen");
|
||
await playAudioKeys(audioKeys, { interrupt: false });
|
||
}
|
||
|
||
// 新回合任务:展示上一回合结算弹窗,弹窗关闭后再允许下一轮继续。
|
||
async function runNewRoundTask(task, runId) {
|
||
// 新回合提示要故意延后一点,避免和上一箭结果展示抢先后顺序。
|
||
const battleInfo = task.message;
|
||
const prevRound = task.restorePrevRound || currentRound.value;
|
||
await new Promise((resolve) => setTimeout(resolve, ROUND_TIP_DELAY));
|
||
if (!isQueueAlive(runId)) return;
|
||
|
||
hideRestoreLoading();
|
||
showRoundTip.value = true;
|
||
isFinalShoot.value = !!battleInfo.current?.goldRound;
|
||
scores.value = [];
|
||
blueScores.value = [];
|
||
roundTipRound.value = prevRound;
|
||
roundResults.value = Array.isArray(battleInfo.rounds) ? battleInfo.rounds : roundResults.value;
|
||
bluePoints.value = battleInfo.teams?.[1]?.score ?? bluePoints.value;
|
||
redPoints.value = battleInfo.teams?.[2]?.score ?? redPoints.value;
|
||
updateGoldenRound(battleInfo);
|
||
|
||
if (battleInfo.current?.goldRound) {
|
||
store.updateShotInfo(0, 0);
|
||
} else if (battleInfo?.shootNumber) {
|
||
// 私有 HeaderProgress 不再直接消费 socket,普通新回合由页面主动归零箭数。
|
||
store.updateShotInfo(0, battleInfo.shootNumber);
|
||
}
|
||
|
||
const latestRound = battleInfo.rounds?.[prevRound - 1];
|
||
if (latestRound) {
|
||
if (battleInfo.current?.goldRound) {
|
||
currentBluePoint.value = battleInfo.teams?.[1]?.score ?? 0;
|
||
currentRedPoint.value = battleInfo.teams?.[2]?.score ?? 0;
|
||
} else {
|
||
currentBluePoint.value = latestRound.scores?.[1]?.score ?? 0;
|
||
currentRedPoint.value = latestRound.scores?.[2]?.score ?? 0;
|
||
}
|
||
} else {
|
||
currentBluePoint.value = 0;
|
||
currentRedPoint.value = 0;
|
||
}
|
||
pendingRoundAudio = true;
|
||
}
|
||
|
||
// 终局任务:播放结束语音后,根据状态跳结果页或返回上一页。
|
||
async function runBattleEndTask(task, runId) {
|
||
const battleInfo = task.message;
|
||
applyBattleBase(battleInfo);
|
||
battleEnded = true;
|
||
clearXRingStreaks();
|
||
matchStatus.value = battleInfo.status;
|
||
if (battleInfo.status === 4) {
|
||
showRoundTip.value = true;
|
||
currentBluePoint.value = 0;
|
||
currentRedPoint.value = 0;
|
||
}
|
||
|
||
// 终局语音必须完整播完,再决定跳转或返回。
|
||
await playAudioKeys("比赛结束", { interrupt: false, timeout: AUDIO_TIMEOUT_MAX });
|
||
if (!isQueueAlive(runId)) return;
|
||
|
||
if (matchStatus.value === 2) {
|
||
uni.redirectTo({
|
||
url: `/pages/friend-battle-result?battleId=${battleId.value}`,
|
||
});
|
||
} else if (matchStatus.value === 4) {
|
||
setTimeout(() => {
|
||
uni.navigateBack();
|
||
}, BATTLE_CANCEL_RETURN_DELAY);
|
||
}
|
||
}
|
||
|
||
// 无效射击任务:弹 toast 并播报无效射击,不修改比赛主状态。
|
||
async function runInvalidShotTask(runId) {
|
||
if (!isQueueAlive(runId)) return;
|
||
uni.showToast({
|
||
title: "距离不足,无效",
|
||
icon: "none",
|
||
});
|
||
await playAudioKeys("射击无效", { interrupt: false });
|
||
}
|
||
|
||
// 回前台恢复入口:拉取服务端快照,处理结束态,然后继续消费增量队列。
|
||
async function restoreLatestBattle() {
|
||
if (!battleId.value) return;
|
||
const currentRestoreId = ++restoreGeneration;
|
||
showRestoreLoading();
|
||
|
||
await new Promise((resolve) => setTimeout(resolve, RESTORE_DELAY));
|
||
if (currentRestoreId !== restoreGeneration) return;
|
||
|
||
let result = null;
|
||
try {
|
||
result = await getBattleAPI(battleId.value);
|
||
} catch (err) {
|
||
console.log("restore latest battle failed:", err);
|
||
}
|
||
if (currentRestoreId !== restoreGeneration) return;
|
||
if (!result) {
|
||
hideRestoreLoading();
|
||
runBattleQueue();
|
||
return;
|
||
}
|
||
|
||
const snapshotTime = getServerTime(result);
|
||
if (snapshotTime) {
|
||
latestSnapshotServerTime.value = Math.max(latestSnapshotServerTime.value, snapshotTime);
|
||
latestAppliedServerTime.value = Math.max(latestAppliedServerTime.value, snapshotTime);
|
||
}
|
||
|
||
const restoreEventType = Number(result?.eventType || 0);
|
||
|
||
if (result.status === 2) {
|
||
clearXRingStreaks();
|
||
hideRestoreLoading();
|
||
uni.redirectTo({
|
||
url: `/pages/friend-battle-result?battleId=${result.matchId}`,
|
||
});
|
||
return;
|
||
}
|
||
if (result.status === 4) {
|
||
clearXRingStreaks();
|
||
}
|
||
|
||
if (restoreEventType === MESSAGETYPESV2.NewRound) {
|
||
const prevRound = getRestorePrevRound(result);
|
||
const restoreMessage = { ...result, type: MESSAGETYPESV2.NewRound };
|
||
const restoreKey = getMessageKey(restoreMessage);
|
||
applyBattleSnapshot(result, { restore: true, restoreEventType });
|
||
|
||
if (!handledMessageKeys.has(restoreKey) && !queuedMessageKeys.has(restoreKey)) {
|
||
queuedMessageKeys.add(restoreKey);
|
||
battleQueue.value.unshift({
|
||
message: result,
|
||
type: MESSAGETYPESV2.NewRound,
|
||
key: restoreKey,
|
||
serverTime: snapshotTime,
|
||
receivedAt: Date.now(),
|
||
order: ++queueOrder,
|
||
restorePrevRound: prevRound,
|
||
});
|
||
}
|
||
|
||
runBattleQueue();
|
||
return;
|
||
}
|
||
|
||
applyBattleSnapshot(result, { restore: true, restoreEventType });
|
||
runBattleQueue();
|
||
}
|
||
|
||
function scheduleRestoreLatestBattle() {
|
||
if (pendingRestoreTimer) {
|
||
clearTimeout(pendingRestoreTimer);
|
||
pendingRestoreTimer = null;
|
||
}
|
||
|
||
if (battleId.value) {
|
||
restoreLatestBattle();
|
||
return;
|
||
}
|
||
|
||
pendingRestoreTimer = setTimeout(() => {
|
||
pendingRestoreTimer = null;
|
||
if (battleId.value) restoreLatestBattle();
|
||
}, RESTORE_EMPTY_ID_RETRY_DELAY);
|
||
}
|
||
|
||
// 页面统一的 socket 回调,只负责把消息送进战况队列。
|
||
function onReceiveMessage(message) {
|
||
enqueueBattleMessage(message);
|
||
}
|
||
|
||
// 新对局入口:彻底清空上一局残留的状态、队列和缓存。
|
||
onLoad((options) => {
|
||
console.log('重新进入了')
|
||
// 新对局入口:把所有会串场的状态、队列、时间戳和缓存一次性清空。
|
||
start.value = null;
|
||
tips.value = "";
|
||
battleId.value = options.battleId || "";
|
||
currentRound.value = 0;
|
||
roundTipRound.value = 0;
|
||
goldenRound.value = 0;
|
||
currentRedPoint.value = 0;
|
||
currentBluePoint.value = 0;
|
||
scores.value = [];
|
||
blueScores.value = [];
|
||
redTeam.value = [];
|
||
blueTeam.value = [];
|
||
currentShooterId.value = 0;
|
||
roundResults.value = [];
|
||
redPoints.value = 0;
|
||
bluePoints.value = 0;
|
||
showRoundTip.value = false;
|
||
isFinalShoot.value = false;
|
||
matchStatus.value = undefined;
|
||
shootTimeTotal.value = DEFAULT_SHOOT_TIME;
|
||
showOfflineModal.value = false;
|
||
hideRestoreLoading();
|
||
loadXRingStreaks();
|
||
queueGeneration += 1;
|
||
battleQueue.value = [];
|
||
queueRunning.value = false;
|
||
latestSnapshotServerTime.value = 0;
|
||
latestAppliedServerTime.value = 0;
|
||
restoreGeneration += 1;
|
||
pendingRoundAudio = false;
|
||
battleEnded = false;
|
||
handledMessageKeys.clear();
|
||
handledMessageKeyOrder.length = 0;
|
||
queuedMessageKeys.clear();
|
||
progressDeadlineAt = 0;
|
||
clearProgressZeroWaiters();
|
||
cancelRoundTipDisplay();
|
||
store.updateShotInfo(0, 0);
|
||
store.updateTips("");
|
||
latestShotFlash.value = null;
|
||
scheduleRestoreLatestBattle();
|
||
});
|
||
|
||
// 挂载后开启常亮、监听 socket 和语音事件,并关闭激光瞄准。
|
||
onMounted(async () => {
|
||
uni.setKeepScreenOn({
|
||
keepScreenOn: true,
|
||
});
|
||
uni.$on("socket-inbox", onReceiveMessage);
|
||
uni.$on("audioEnded", onAudioEnded);
|
||
uni.$on(AUDIO_INTERRUPTION_BEGIN_EVENT, handleBattleCovered);
|
||
uni.$on(AUDIO_INTERRUPTION_END_EVENT, handleBattleRecovered);
|
||
uni.$on(PROGRESS_ZERO_EVENT, onProgressZero);
|
||
uni.$on(COUNTDOWN_READY_EVENT, hideRestoreLoading);
|
||
await laserCloseAPI();
|
||
});
|
||
|
||
// 离开页面时清理监听、停止音频,并让当前队列整体失效。
|
||
onBeforeUnmount(() => {
|
||
uni.setKeepScreenOn({
|
||
keepScreenOn: false,
|
||
});
|
||
uni.$off("socket-inbox", onReceiveMessage);
|
||
uni.$off("audioEnded", onAudioEnded);
|
||
uni.$off(PROGRESS_ZERO_EVENT, onProgressZero);
|
||
uni.$off(COUNTDOWN_READY_EVENT, hideRestoreLoading);
|
||
if (pendingRestoreTimer) {
|
||
clearTimeout(pendingRestoreTimer);
|
||
pendingRestoreTimer = null;
|
||
}
|
||
hideRestoreLoading();
|
||
invalidateBattleQueue({ stopAudio: true, stopProgress: true });
|
||
console.log('onBeforeUnmount', '页面卸载前')
|
||
audioManager.stopAll();
|
||
uni.$off(AUDIO_INTERRUPTION_BEGIN_EVENT, handleBattleCovered);
|
||
uni.$off(AUDIO_INTERRUPTION_END_EVENT, handleBattleRecovered);
|
||
});
|
||
|
||
onHide(()=>{
|
||
console.log('onHide', '页面大退')
|
||
handleBattleCovered();
|
||
})
|
||
|
||
// 每次回到前台都重新拉最新比赛快照,确保画面与后端一致。
|
||
onShow(() => {
|
||
console.log('onshow')
|
||
scheduleRestoreLatestBattle();
|
||
});
|
||
</script>
|
||
|
||
<template>
|
||
<Container
|
||
:bgType="start ? 3 : 1"
|
||
:loading="restoreLoading"
|
||
loadingText="正在恢复比赛..."
|
||
>
|
||
<view class="container">
|
||
<!-- 未正式开局时显示等待态:头图、队伍信息和测试距离。 -->
|
||
<BattleHeader
|
||
v-if="start === false"
|
||
:redTeam="redTeam"
|
||
:blueTeam="blueTeam"
|
||
:winner="0"
|
||
/>
|
||
<TestDistance v-if="start === false" :guide="false" :isBattle="true"/>
|
||
<!-- 比赛进行中显示:左右队伍、进度条、靶面和底部比分。 -->
|
||
<view v-if="start" class="players-row">
|
||
<TeamAvatars
|
||
:team="blueTeam"
|
||
:isRed="false"
|
||
:currentShooterId="currentShooterId"
|
||
/>
|
||
<ShootProgress2
|
||
:tips="tips"
|
||
:total="shootTimeTotal"
|
||
:currentRound="
|
||
goldenRound > 0 ? 'gold' + goldenRound : 'round' + currentRound
|
||
"
|
||
/>
|
||
<TeamAvatars :team="redTeam" :currentShooterId="currentShooterId"/>
|
||
</view>
|
||
<BowTarget
|
||
v-if="start"
|
||
mode="team"
|
||
:scores="scores"
|
||
:blueScores="blueScores"
|
||
:latestShotFlash="latestShotFlash"
|
||
/>
|
||
<BattleFooter
|
||
v-if="start"
|
||
:roundResults="roundResults"
|
||
:redPoints="redPoints"
|
||
:bluePoints="bluePoints"
|
||
:goldenRound="goldenRound"
|
||
/>
|
||
<!-- 回合结束提示层:展示上一回合结果,并在自动关闭后进入下一轮。 -->
|
||
<ScreenHint
|
||
:show="showRoundTip"
|
||
:mode="isFinalShoot ? 'tall' : 'normal'"
|
||
>
|
||
<RoundEndTip
|
||
v-if="showRoundTip"
|
||
:isFinal="isFinalShoot"
|
||
:round="roundTipRound"
|
||
:bluePoint="currentBluePoint"
|
||
:redPoint="currentRedPoint"
|
||
:roundData="
|
||
roundResults[roundTipRound - 1] ? roundResults[roundTipRound - 1] : []
|
||
"
|
||
:onAutoClose="handleRoundTipAutoClose"
|
||
/>
|
||
</ScreenHint>
|
||
<!-- 设备离线提示弹窗:前台检测到在线状态变化时展示。 -->
|
||
<SModal
|
||
:show="showOfflineModal"
|
||
:noBg="true"
|
||
height="360rpx"
|
||
:onClose="() => (showOfflineModal = false)"
|
||
>
|
||
<view class="offline-modal">
|
||
<text class="offline-title">设备已离线</text>
|
||
<text class="offline-desc">检测到设备已断开连接,请检查设备后继续比赛</text>
|
||
<SButton @click="showOfflineModal = false">我知道了</SButton>
|
||
</view>
|
||
</SModal>
|
||
</view>
|
||
</Container>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.container {
|
||
width: 100%;
|
||
height: 100%;
|
||
}
|
||
|
||
.players-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin-top: -2%;
|
||
margin-bottom: 6%;
|
||
}
|
||
|
||
.offline-modal {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: 60rpx 40rpx 40rpx;
|
||
gap: 24rpx;
|
||
}
|
||
|
||
.offline-title {
|
||
font-size: 36rpx;
|
||
font-weight: bold;
|
||
color: #FED847;
|
||
}
|
||
|
||
.offline-desc {
|
||
font-size: 28rpx;
|
||
color: #CCCCCC;
|
||
text-align: center;
|
||
line-height: 1.6;
|
||
}
|
||
</style>
|