Files
shoot-miniprograms/src/pages/team-battle/index.vue
2026-05-26 11:43:35 +08:00

1278 lines
41 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>