update:新增个人训练重连机制,修复海报

This commit is contained in:
2026-07-24 16:00:46 +08:00
parent f316d52cec
commit c5618fb60b
12 changed files with 811 additions and 69 deletions
+5 -1
View File
@@ -8,7 +8,7 @@ try {
switch (envVersion) {
case "develop": // 开发版
// BASE_URL = "http://192.168.1.5:8000/api/shoot";
// BASE_URL = "http://192.168.1.2:8000/api/shoot";
BASE_URL = "https://apitest.shelingxingqiu.com/api/shoot";
break;
case "trial": // 体验版
@@ -269,6 +269,10 @@ export const createPractiseV2API = (trainingType, difficultyLevel) => {
});
};
export const getCurrentPractiseAPI = () => {
return request("GET", "/user/practice/current");
};
export const startPractiseAPI = (id) => {
return request("POST", "/user/practice/begin", { id });
};
+32 -15
View File
@@ -635,7 +635,7 @@ export function renderScores(ctx, arrows = [], bgImg) {
);
} else {
ctx.drawImage(
"../static/score-bg.png",
"/static/score-bg.png",
16 + (i % 9) * 30,
290 + Math.ceil((i + 1) / 9) * 30,
27,
@@ -657,7 +657,7 @@ export function renderScores(ctx, arrows = [], bgImg) {
ctx.drawImage(bgImg, 24 + rowIndex * 42, i > 5 ? 362 : 320, 38, 38);
} else {
ctx.drawImage(
"../static/score-bg.png",
"/static/score-bg.png",
24 + rowIndex * 42,
i > 5 ? 362 : 320,
38,
@@ -706,22 +706,39 @@ export async function sharePractiseData(canvasId, type, user, data) {
);
const bgImg = await loadCanvasImage(canvas, bgImgSrc);
const avatarImgPromise = loadImage(user.avatar).then((path) =>
loadCanvasImage(canvas, path)
);
const lvlImgPromise = loadImage(user.lvlImage).then((path) =>
loadCanvasImage(canvas, path)
);
// 头像与段位框属于装饰图片,缺失时使用兜底或跳过,避免阻断分享。
const loadProfileImage = async (src, fallbackSrc = "", label = "") => {
const normalizedSrc =
typeof src === "string" && src.startsWith("../static/")
? src.slice(2)
: src;
if (normalizedSrc) {
try {
const path = await loadImage(normalizedSrc);
return await loadCanvasImage(canvas, path);
} catch (error) {
console.warn(`share ${label} image load failed`, error);
}
}
return fallbackSrc ? loadCanvasImage(canvas, fallbackSrc) : null;
};
let titleImageSrc = "../static/first-try-title.png";
const avatarImgPromise = loadProfileImage(
user?.avatar,
"/static/user-icon.png",
"avatar"
);
const lvlImgPromise = loadProfileImage(user?.lvlImage, "", "level");
let titleImageSrc = "/static/first-try-title.png";
if (type == 2) {
titleImageSrc = "../static/practise-one-title.png";
titleImageSrc = "/static/practise-one-title.png";
} else if (type == 3) {
titleImageSrc = "../static/practise-two-title.png";
titleImageSrc = "/static/practise-two-title.png";
}
const titleImgPromise = loadCanvasImage(canvas, titleImageSrc);
const scoreBgImgPromise = loadCanvasImage(canvas, "../static/score-bg.png");
const qrCodeImgPromise = loadCanvasImage(canvas, "../static/qr-code.png");
const scoreBgImgPromise = loadCanvasImage(canvas, "/static/score-bg.png");
const qrCodeImgPromise = loadCanvasImage(canvas, "/static/qr-code.png");
const [avatarImg, lvlImg, titleImg, scoreBgImg, qrCodeImg] =
await Promise.all([
@@ -738,8 +755,8 @@ export async function sharePractiseData(canvasId, type, user, data) {
ctx.drawImage(bgImg, 0, 0, width, height);
drawRoundImage(ctx, avatarImg, 17, 20, 32, 32, 20);
ctx.drawImage(lvlImg, 12, 15, 42, 42);
if (avatarImg) drawRoundImage(ctx, avatarImg, 17, 20, 32, 32, 20);
if (lvlImg) ctx.drawImage(lvlImg, 12, 15, 42, 42);
renderText(ctx, user.nickName, 13, "#fff", 58, 34);
renderRankTitle(ctx, user.lvlName);
+3 -3
View File
@@ -38,7 +38,7 @@ const isMember = (player = {}) => player.vip === true || player.sVip === true;
</script>
<template>
<view class="container" :style="{ paddingTop: showHeader ? '5px' : '0' }">
<view class="container">
<image
v-if="showHeader"
:src="`https://static.shelingxingqiu.com/shootmini/static/battle-header${players.length ? '-melee' : ''}.png`"
@@ -47,7 +47,7 @@ const isMember = (player = {}) => player.vip === true || player.sVip === true;
<view
v-if="!players.length && blueTeam.length && redTeam.length"
class="players"
:style="{ paddingTop: showHeader ? '15px' : '0' }"
:style="{ paddingTop: showHeader ? '48rpx' : '0' }"
>
<view>
<view
@@ -158,7 +158,7 @@ const isMember = (player = {}) => player.vip === true || player.sVip === true;
.container > image:first-child {
position: absolute;
width: 100%;
top: -5px;
/* top: -5px; */
z-index: 1;
pointer-events: none;
}
+11 -1
View File
@@ -12,7 +12,7 @@ import PointSwitcher from "@/components/PointSwitcher.vue";
import BowShotEffect from "@/components/BowShotEffect.vue";
import { MESSAGETYPES, MESSAGETYPESV2 } from "@/constants";
import { simulShootAPI } from "@/apis";
import { simulShootAPI, laserAimAPI, laserCloseAPI } from "@/apis";
import useStore from "@/store";
import { storeToRefs } from "pinia";
const store = useStore();
@@ -357,6 +357,14 @@ const simulShoot2 = async () => {
}
};
const openAim = async () => {
await laserAimAPI();
};
const closeAim = async () => {
await laserCloseAPI();
};
const env = computed(() => {
const accountInfo = uni.getAccountInfoSync();
return accountInfo.miniProgram.envVersion;
@@ -524,6 +532,8 @@ onBeforeUnmount(() => {
<view class="simul" v-if="env !== 'release'">
<button @click="simulShoot">模拟</button>
<button @click="simulShoot2">射箭</button>
<button @click="openAim">开瞄</button>
<button @click="closeAim">关瞄</button>
</view>
</view>
</template>
+17 -1
View File
@@ -849,6 +849,7 @@ export function connectMatchWebSocket(options = {}) {
token,
mode,
requestPracticeInfoOnOpen = false,
appHideResumable = false,
force = false,
reconnecting = false,
reconnectReason = "",
@@ -920,6 +921,7 @@ export function connectMatchWebSocket(options = {}) {
isMelee:
Number.isFinite(normalizedMode) ? normalizedMode > 3 : undefined,
requestPracticeInfoOnOpen: requestPracticeInfoOnOpen === true,
appHideResumable: appHideResumable === true,
meleeHalfRest: isSameContext
? currentContext?.meleeHalfRest === true
: false,
@@ -1016,9 +1018,22 @@ export function connectMatchWebSocketFromNotice(notice, fallbackUserId) {
});
}
export function setMatchAppHideResumable(enabled) {
if (!currentContext) return;
currentContext.appHideResumable = enabled === true;
}
export function closeMatchWebSocket(options = {}) {
// 默认关闭时会发送 LEAVE;内部切换连接可通过 sendLeave:false 跳过。
const { sendLeave: shouldSendLeave = true, reason = "manual" } = options;
const { reason = "manual" } = options;
// 可恢复训练切后台只断开传输层,不能把它上报成主动离场。
const shouldSendLeave =
options.sendLeave === undefined
? !(
reason === "app-hide" &&
currentContext?.appHideResumable === true
)
: options.sendLeave === true;
manualClose = true;
clearReconnectTimer();
@@ -1051,6 +1066,7 @@ export default {
ServerMessageType,
connectMatchWebSocket,
connectMatchWebSocketFromNotice,
setMatchAppHideResumable,
closeMatchWebSocket,
forceReconnectMatchWebSocket,
handleMatchNetworkStatusChange,
+56 -3
View File
@@ -13,10 +13,10 @@ const data = ref({
rounds: [],
});
const players = ref([]);
const isLoading = ref(true);
const loadError = ref("");
onLoad(async (options) => {
if (!options.battleId) return;
battleId.value = options.battleId || "60510101693403136";
const loadBattle = async () => {
const result = await getBattleAPI(battleId.value);
data.value = result;
if (result.mode > 3) {
@@ -62,6 +62,32 @@ onLoad(async (options) => {
players.value = [...rankedPlayers, ...unrankedPlayers];
}
// 数据和派生列表均已就绪后再关闭占位,避免空壳闪烁。
isLoading.value = false;
};
const requestBattle = () => {
if (!battleId.value) return;
isLoading.value = true;
loadError.value = "";
loadBattle().catch((error) => {
console.error("加载比赛详情失败:", error);
loadError.value = "赛况加载失败";
isLoading.value = false;
});
};
onLoad((options) => {
if (!options.battleId) {
loadError.value = "缺少比赛信息";
isLoading.value = false;
return;
}
battleId.value = options.battleId;
requestBattle();
});
const checkBowData = (selected) => {
@@ -80,6 +106,17 @@ const checkBowData = (selected) => {
<template>
<Container title="详情">
<view class="container">
<view v-if="isLoading" class="page-state">
<text>赛况加载中...</text>
</view>
<view
v-else-if="loadError"
class="page-state page-state--error"
@click="requestBattle"
>
<text>{{ loadError }}</text>
<text v-if="battleId" class="retry-text">点击重新加载</text>
</view>
<BattleHeader
v-if="data.mode <= 3"
:winner="data.winTeam"
@@ -165,6 +202,22 @@ const checkBowData = (selected) => {
width: 100%;
height: 100%;
}
.page-state {
min-height: 60vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(255, 255, 255, 0.6);
font-size: 26rpx;
}
.page-state--error {
color: rgba(255, 255, 255, 0.72);
}
.retry-text {
margin-top: 16rpx;
color: #fed847;
}
.score-header,
.score-row {
display: flex;
+254 -8
View File
@@ -1,6 +1,6 @@
<script setup>
import { ref, onMounted, onBeforeUnmount } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import { ref, nextTick, onMounted, onBeforeUnmount } from "vue";
import { onHide, onLoad, onShow } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import ShootProgress from "@/components/ShootProgress.vue";
import BowTarget from "@/components/BowTarget.vue";
@@ -16,10 +16,17 @@ import audioManager from "@/audioManager";
import {
createPractiseAPI,
endPractiseAPI,
getCurrentPractiseAPI,
getPractiseAPI,
startPractiseAPI,
} from "@/apis";
import { connectMatchWebSocket, closeMatchWebSocket } from "@/matchWebsocket";
import {
connectMatchWebSocket,
closeMatchWebSocket,
setMatchAppHideResumable,
MATCH_WS_PRACTICE_SYNC_EVENT,
MATCH_WS_STATE_EVENT,
} from "@/matchWebsocket";
import { sharePractiseData } from "@/canvas";
import { wxShare, debounce } from "@/util";
import { MESSAGETYPESV2, roundsName } from "@/constants";
@@ -35,11 +42,21 @@ const isSvip = ref(false);
const total = 12;
const practiseResult = ref({});
const practiseId = ref("");
const serverAddr = ref("");
const showGuide = ref(false);
const tips = ref("");
const targetType = ref(1);
const sharing = ref(false);
const exiting = ref(false);
const hiddenWhileActive = ref(false);
const resumeInFlight = ref(false);
const foregroundResumeSyncPending = ref(false);
const pageVisible = ref(true);
const appHideResumable = ref(false);
const restoringSnapshot = ref(false);
let practiceSyncTimer = null;
let waitingPracticeSync = false;
const PRACTICE_SYNC_TIMEOUT_MS = 5000;
const RESULT_TIP_CDN = "https://static.shelingxingqiu.com/shootmini/static";
onLoad((options) => {
@@ -48,6 +65,63 @@ onLoad((options) => {
}
});
const setPracticeAppHideResumable = (enabled) => {
appHideResumable.value = enabled === true;
setMatchAppHideResumable(appHideResumable.value);
};
const clearPracticeSyncTimer = () => {
if (!practiceSyncTimer) return;
clearTimeout(practiceSyncTimer);
practiceSyncTimer = null;
};
const cancelPracticeSyncWait = () => {
waitingPracticeSync = false;
clearPracticeSyncTimer();
};
const startPracticeSyncTimer = () => {
clearPracticeSyncTimer();
waitingPracticeSync = true;
practiceSyncTimer = setTimeout(() => {
practiceSyncTimer = null;
if (!waitingPracticeSync) return;
waitingPracticeSync = false;
foregroundResumeSyncPending.value = false;
hiddenWhileActive.value = false;
closeMatchWebSocket({
reason: "legacy-practice-resume-timeout",
sendLeave: false,
});
uni.showToast({
title: "训练重连失败,请重试",
icon: "none",
});
}, PRACTICE_SYNC_TIMEOUT_MS);
};
const connectPracticeServer = () => {
const latestServerAddr = String(serverAddr.value || "").trim();
if (!practiseId.value || !latestServerAddr) return false;
cancelPracticeSyncWait();
closeMatchWebSocket({
reason: "legacy-practice-switch",
sendLeave: false,
});
connectMatchWebSocket({
serverAddr: latestServerAddr,
matchId: practiseId.value,
userId: user.value.id,
requestPracticeInfoOnOpen: true,
appHideResumable: true,
});
setPracticeAppHideResumable(true);
return true;
};
const createPractise = async () => {
closeMatchWebSocket({ reason: "practice-recreate" });
const result = await createPractiseAPI(
@@ -64,11 +138,8 @@ const createPractise = async () => {
});
return null;
}
connectMatchWebSocket({
serverAddr: result.serverAddr,
matchId: result.id,
userId: user.value.id,
});
serverAddr.value = result.serverAddr;
connectPracticeServer();
return result;
};
@@ -94,6 +165,10 @@ const onReady = async () => {
};
const onOver = async (message) => {
setPracticeAppHideResumable(false);
hiddenWhileActive.value = false;
foregroundResumeSyncPending.value = false;
cancelPracticeSyncWait();
practiseResult.value = Array.isArray(message?.details)
? message
: await getPractiseAPI(practiseId.value);
@@ -105,11 +180,16 @@ async function onReceiveMessage(msg) {
isSvip.value = msg.sVip === true;
scores.value = Array.isArray(msg.details) ? msg.details : scores.value;
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
setPracticeAppHideResumable(false);
hiddenWhileActive.value = false;
foregroundResumeSyncPending.value = false;
cancelPracticeSyncWait();
setTimeout(() => onOver(msg), 1500);
}
}
async function onComplete() {
setPracticeAppHideResumable(false);
const validArrows = (practiseResult.value.details || []).filter(
(a) => a.x !== -30 && a.y !== -30
);
@@ -117,6 +197,7 @@ async function onComplete() {
uni.navigateBack();
} else {
practiseId.value = "";
serverAddr.value = "";
practiseResult.value = {};
start.value = false;
scores.value = [];
@@ -128,6 +209,10 @@ async function onComplete() {
async function exitPractise() {
if (exiting.value) return;
exiting.value = true;
setPracticeAppHideResumable(false);
hiddenWhileActive.value = false;
foregroundResumeSyncPending.value = false;
cancelPracticeSyncWait();
try {
if (practiseId.value && !practiseResult.value?.details) {
@@ -140,6 +225,141 @@ async function exitPractise() {
}
}
const stopMissingCurrentPracticeAndExit = async () => {
if (exiting.value) return;
const localPractiseId = practiseId.value;
exiting.value = true;
setPracticeAppHideResumable(false);
hiddenWhileActive.value = false;
foregroundResumeSyncPending.value = false;
cancelPracticeSyncWait();
try {
if (localPractiseId) {
await endPractiseAPI(localPractiseId);
}
} catch (error) {
console.error("Failed to stop missing current practice", error);
} finally {
closeMatchWebSocket({ reason: "legacy-practice-current-missing" });
practiseId.value = "";
serverAddr.value = "";
practiseResult.value = {};
start.value = false;
scores.value = [];
isSvip.value = false;
uni.showToast({
title: "训练已结束,请重新进入",
icon: "none",
});
uni.navigateBack();
}
};
const resumeCurrentPractice = async () => {
if (
resumeInFlight.value ||
!hiddenWhileActive.value ||
!appHideResumable.value ||
exiting.value
) {
return;
}
resumeInFlight.value = true;
try {
let currentPractice;
try {
currentPractice = await getCurrentPractiseAPI();
} catch (error) {
if (!pageVisible.value || exiting.value) return;
console.error("Failed to get current practice", error);
await stopMissingCurrentPracticeAndExit();
return;
}
if (!pageVisible.value || !hiddenWhileActive.value || exiting.value) return;
const latestPractiseId = currentPractice?.id;
const latestServerAddr = String(currentPractice?.serverAddr || "").trim();
if (
currentPractice === null ||
!latestPractiseId ||
!latestServerAddr
) {
await stopMissingCurrentPracticeAndExit();
return;
}
practiseId.value = latestPractiseId;
serverAddr.value = latestServerAddr;
foregroundResumeSyncPending.value = true;
if (!connectPracticeServer()) {
foregroundResumeSyncPending.value = false;
await stopMissingCurrentPracticeAndExit();
}
} finally {
resumeInFlight.value = false;
}
};
const onMatchSocketState = (event = {}) => {
if (event.state !== "open") return;
if (
event.matchId &&
String(event.matchId) !== String(practiseId.value)
) {
return;
}
startPracticeSyncTimer();
};
const onPracticeInfoSync = async (payload = {}) => {
const responsePractiseId = String(
payload.matchId || payload.practiceInfo?.id || ""
);
if (
!responsePractiseId ||
responsePractiseId !== String(practiseId.value)
) {
return;
}
const snapshot = payload.practiceInfo;
if (!snapshot || typeof snapshot !== "object") return;
cancelPracticeSyncWait();
foregroundResumeSyncPending.value = false;
hiddenWhileActive.value = false;
restoringSnapshot.value = true;
await nextTick();
try {
scores.value = Array.isArray(snapshot.details) ? snapshot.details : [];
isSvip.value = snapshot.sVip === true;
const status = Number(snapshot.status);
if (status === 1) {
start.value = false;
} else if (status === 2) {
start.value = true;
}
} finally {
restoringSnapshot.value = false;
await nextTick();
}
const timeLimit = Number(snapshot.timeLimit);
const duration = Number(snapshot.duration);
if (Number(snapshot.status) === 2 && Number.isFinite(timeLimit) && timeLimit > 0) {
uni.$emit(
"update-remain",
Math.max(0, timeLimit - (Number.isFinite(duration) ? duration : 0))
);
}
};
const getResultTipSrc = (result = {}) => {
const validCount = (result.details || []).filter(
(arrow) => arrow.x !== -30 && arrow.y !== -30
@@ -163,6 +383,25 @@ const onClickShare = debounce(async () => {
}
});
onHide(() => {
pageVisible.value = false;
if (
!appHideResumable.value ||
exiting.value ||
!practiseId.value ||
practiseResult.value?.details
) {
return;
}
hiddenWhileActive.value = true;
});
onShow(async () => {
pageVisible.value = true;
await resumeCurrentPractice();
});
onMounted(async () => {
void audioManager.warmAll();
// audioManager.play("第一轮");
@@ -170,15 +409,21 @@ onMounted(async () => {
keepScreenOn: true,
});
uni.$on("socket-inbox", onReceiveMessage);
uni.$on(MATCH_WS_PRACTICE_SYNC_EVENT, onPracticeInfoSync);
uni.$on(MATCH_WS_STATE_EVENT, onMatchSocketState);
uni.$on("share-image", onClickShare);
await createPractise();
});
onBeforeUnmount(() => {
setPracticeAppHideResumable(false);
cancelPracticeSyncWait();
uni.setKeepScreenOn({
keepScreenOn: false,
});
uni.$off("socket-inbox", onReceiveMessage);
uni.$off(MATCH_WS_PRACTICE_SYNC_EVENT, onPracticeInfoSync);
uni.$off(MATCH_WS_STATE_EVENT, onMatchSocketState);
uni.$off("share-image", onClickShare);
audioManager.stopAll();
closeMatchWebSocket({ reason: "practice-leave" });
@@ -215,6 +460,7 @@ onBeforeUnmount(() => {
<BowPower />
</view>
<BowTarget
v-if="!restoringSnapshot"
:totalRound="start ? total / 4 : 0"
:currentRound="scores.length % 3"
:scores="scores"
+254 -8
View File
@@ -1,5 +1,6 @@
<script setup>
import { ref, onMounted, onBeforeUnmount } from "vue";
import { ref, nextTick, onMounted, onBeforeUnmount } from "vue";
import { onHide, onLoad, onShow } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import ShootProgress from "@/components/ShootProgress.vue";
import BowTarget from "@/components/BowTarget.vue";
@@ -15,17 +16,23 @@ import audioManager from "@/audioManager";
import {
createPractiseAPI,
endPractiseAPI,
getCurrentPractiseAPI,
getPractiseAPI,
startPractiseAPI,
} from "@/apis";
import { connectMatchWebSocket, closeMatchWebSocket } from "@/matchWebsocket";
import {
connectMatchWebSocket,
closeMatchWebSocket,
setMatchAppHideResumable,
MATCH_WS_PRACTICE_SYNC_EVENT,
MATCH_WS_STATE_EVENT,
} from "@/matchWebsocket";
import { sharePractiseData } from "@/canvas";
import { wxShare, debounce } from "@/util";
import { MESSAGETYPESV2 } from "@/constants";
import useStore from "@/store";
import { storeToRefs } from "pinia";
import {onLoad} from "@dcloudio/uni-app";
const store = useStore();
const { user, device } = storeToRefs(store);
@@ -35,10 +42,20 @@ const isSvip = ref(false);
const total = 36;
const practiseResult = ref({});
const practiseId = ref("");
const serverAddr = ref("");
const showGuide = ref(false);
const targetType = ref(1);
const sharing = ref(false);
const exiting = ref(false);
const hiddenWhileActive = ref(false);
const resumeInFlight = ref(false);
const foregroundResumeSyncPending = ref(false);
const pageVisible = ref(true);
const appHideResumable = ref(false);
const restoringSnapshot = ref(false);
let practiceSyncTimer = null;
let waitingPracticeSync = false;
const PRACTICE_SYNC_TIMEOUT_MS = 5000;
const RESULT_TIP_CDN = "https://static.shelingxingqiu.com/shootmini/static";
onLoad((options) => {
@@ -47,6 +64,63 @@ onLoad((options) => {
}
});
const setPracticeAppHideResumable = (enabled) => {
appHideResumable.value = enabled === true;
setMatchAppHideResumable(appHideResumable.value);
};
const clearPracticeSyncTimer = () => {
if (!practiceSyncTimer) return;
clearTimeout(practiceSyncTimer);
practiceSyncTimer = null;
};
const cancelPracticeSyncWait = () => {
waitingPracticeSync = false;
clearPracticeSyncTimer();
};
const startPracticeSyncTimer = () => {
clearPracticeSyncTimer();
waitingPracticeSync = true;
practiceSyncTimer = setTimeout(() => {
practiceSyncTimer = null;
if (!waitingPracticeSync) return;
waitingPracticeSync = false;
foregroundResumeSyncPending.value = false;
hiddenWhileActive.value = false;
closeMatchWebSocket({
reason: "legacy-practice-resume-timeout",
sendLeave: false,
});
uni.showToast({
title: "训练重连失败,请重试",
icon: "none",
});
}, PRACTICE_SYNC_TIMEOUT_MS);
};
const connectPracticeServer = () => {
const latestServerAddr = String(serverAddr.value || "").trim();
if (!practiseId.value || !latestServerAddr) return false;
cancelPracticeSyncWait();
closeMatchWebSocket({
reason: "legacy-practice-switch",
sendLeave: false,
});
connectMatchWebSocket({
serverAddr: latestServerAddr,
matchId: practiseId.value,
userId: user.value.id,
requestPracticeInfoOnOpen: true,
appHideResumable: true,
});
setPracticeAppHideResumable(true);
return true;
};
const createPractise = async () => {
closeMatchWebSocket({ reason: "practice-recreate" });
const result = await createPractiseAPI(
@@ -63,11 +137,8 @@ const createPractise = async () => {
});
return null;
}
connectMatchWebSocket({
serverAddr: result.serverAddr,
matchId: result.id,
userId: user.value.id,
});
serverAddr.value = result.serverAddr;
connectPracticeServer();
return result;
};
@@ -93,6 +164,10 @@ const onReady = async () => {
};
const onOver = async (message) => {
setPracticeAppHideResumable(false);
hiddenWhileActive.value = false;
foregroundResumeSyncPending.value = false;
cancelPracticeSyncWait();
practiseResult.value = Array.isArray(message?.details)
? message
: await getPractiseAPI(practiseId.value);
@@ -104,6 +179,10 @@ async function onReceiveMessage(msg) {
isSvip.value = msg.sVip === true;
scores.value = Array.isArray(msg.details) ? msg.details : scores.value;
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
setPracticeAppHideResumable(false);
hiddenWhileActive.value = false;
foregroundResumeSyncPending.value = false;
cancelPracticeSyncWait();
setTimeout(() => onOver(msg), 1500);
}
// messages.forEach((msg) => {
@@ -125,6 +204,7 @@ async function onReceiveMessage(msg) {
}
async function onComplete() {
setPracticeAppHideResumable(false);
const validArrows = (practiseResult.value.details || []).filter(
(a) => a.x !== -30 && a.y !== -30
);
@@ -132,6 +212,7 @@ async function onComplete() {
uni.navigateBack();
} else {
practiseId.value = "";
serverAddr.value = "";
practiseResult.value = {};
start.value = false;
scores.value = [];
@@ -143,6 +224,10 @@ async function onComplete() {
async function exitPractise() {
if (exiting.value) return;
exiting.value = true;
setPracticeAppHideResumable(false);
hiddenWhileActive.value = false;
foregroundResumeSyncPending.value = false;
cancelPracticeSyncWait();
try {
if (practiseId.value && !practiseResult.value?.details) {
@@ -155,6 +240,141 @@ async function exitPractise() {
}
}
const stopMissingCurrentPracticeAndExit = async () => {
if (exiting.value) return;
const localPractiseId = practiseId.value;
exiting.value = true;
setPracticeAppHideResumable(false);
hiddenWhileActive.value = false;
foregroundResumeSyncPending.value = false;
cancelPracticeSyncWait();
try {
if (localPractiseId) {
await endPractiseAPI(localPractiseId);
}
} catch (error) {
console.error("Failed to stop missing current practice", error);
} finally {
closeMatchWebSocket({ reason: "legacy-practice-current-missing" });
practiseId.value = "";
serverAddr.value = "";
practiseResult.value = {};
start.value = false;
scores.value = [];
isSvip.value = false;
uni.showToast({
title: "训练已结束,请重新进入",
icon: "none",
});
uni.navigateBack();
}
};
const resumeCurrentPractice = async () => {
if (
resumeInFlight.value ||
!hiddenWhileActive.value ||
!appHideResumable.value ||
exiting.value
) {
return;
}
resumeInFlight.value = true;
try {
let currentPractice;
try {
currentPractice = await getCurrentPractiseAPI();
} catch (error) {
if (!pageVisible.value || exiting.value) return;
console.error("Failed to get current practice", error);
await stopMissingCurrentPracticeAndExit();
return;
}
if (!pageVisible.value || !hiddenWhileActive.value || exiting.value) return;
const latestPractiseId = currentPractice?.id;
const latestServerAddr = String(currentPractice?.serverAddr || "").trim();
if (
currentPractice === null ||
!latestPractiseId ||
!latestServerAddr
) {
await stopMissingCurrentPracticeAndExit();
return;
}
practiseId.value = latestPractiseId;
serverAddr.value = latestServerAddr;
foregroundResumeSyncPending.value = true;
if (!connectPracticeServer()) {
foregroundResumeSyncPending.value = false;
await stopMissingCurrentPracticeAndExit();
}
} finally {
resumeInFlight.value = false;
}
};
const onMatchSocketState = (event = {}) => {
if (event.state !== "open") return;
if (
event.matchId &&
String(event.matchId) !== String(practiseId.value)
) {
return;
}
startPracticeSyncTimer();
};
const onPracticeInfoSync = async (payload = {}) => {
const responsePractiseId = String(
payload.matchId || payload.practiceInfo?.id || ""
);
if (
!responsePractiseId ||
responsePractiseId !== String(practiseId.value)
) {
return;
}
const snapshot = payload.practiceInfo;
if (!snapshot || typeof snapshot !== "object") return;
cancelPracticeSyncWait();
foregroundResumeSyncPending.value = false;
hiddenWhileActive.value = false;
restoringSnapshot.value = true;
await nextTick();
try {
scores.value = Array.isArray(snapshot.details) ? snapshot.details : [];
isSvip.value = snapshot.sVip === true;
const status = Number(snapshot.status);
if (status === 1) {
start.value = false;
} else if (status === 2) {
start.value = true;
}
} finally {
restoringSnapshot.value = false;
await nextTick();
}
const timeLimit = Number(snapshot.timeLimit);
const duration = Number(snapshot.duration);
if (Number(snapshot.status) === 2 && Number.isFinite(timeLimit) && timeLimit > 0) {
uni.$emit(
"update-remain",
Math.max(0, timeLimit - (Number.isFinite(duration) ? duration : 0))
);
}
};
const getResultTipSrc = (result = {}) => {
const validCount = (result.details || []).filter(
(arrow) => arrow.x !== -30 && arrow.y !== -30
@@ -178,21 +398,46 @@ const onClickShare = debounce(async () => {
}
});
onHide(() => {
pageVisible.value = false;
if (
!appHideResumable.value ||
exiting.value ||
!practiseId.value ||
practiseResult.value?.details
) {
return;
}
hiddenWhileActive.value = true;
});
onShow(async () => {
pageVisible.value = true;
await resumeCurrentPractice();
});
onMounted(async () => {
void audioManager.warmAll();
uni.setKeepScreenOn({
keepScreenOn: true,
});
uni.$on("socket-inbox", onReceiveMessage);
uni.$on(MATCH_WS_PRACTICE_SYNC_EVENT, onPracticeInfoSync);
uni.$on(MATCH_WS_STATE_EVENT, onMatchSocketState);
uni.$on("share-image", onClickShare);
await createPractise();
});
onBeforeUnmount(() => {
setPracticeAppHideResumable(false);
cancelPracticeSyncWait();
uni.setKeepScreenOn({
keepScreenOn: false,
});
uni.$off("socket-inbox", onReceiveMessage);
uni.$off(MATCH_WS_PRACTICE_SYNC_EVENT, onPracticeInfoSync);
uni.$off(MATCH_WS_STATE_EVENT, onMatchSocketState);
uni.$off("share-image", onClickShare);
audioManager.stopAll();
closeMatchWebSocket({ reason: "practice-leave" });
@@ -224,6 +469,7 @@ onBeforeUnmount(() => {
<BowPower />
</view>
<BowTarget
v-if="!restoringSnapshot"
:currentRound="scores.length"
:totalRound="start ? total : 0"
:scores="scores"
+11 -1
View File
@@ -12,7 +12,7 @@ import PointSwitcher from "./PointSwitcher.vue";
import BowShotEffect from "@/components/BowShotEffect.vue";
import { MESSAGETYPES, MESSAGETYPESV2 } from "@/constants";
import { simulShootAPI } from "@/apis";
import { simulShootAPI, laserAimAPI, laserCloseAPI } from "@/apis";
import useStore from "@/store";
import { storeToRefs } from "pinia";
const store = useStore();
@@ -363,6 +363,14 @@ const simulShoot2 = async () => {
}
};
const openAim = async () => {
await laserAimAPI();
};
const closeAim = async () => {
await laserCloseAPI();
};
const env = computed(() => {
const accountInfo = uni.getAccountInfoSync();
return accountInfo.miniProgram.envVersion;
@@ -542,6 +550,8 @@ onBeforeUnmount(() => {
<view class="simul" v-if="env !== 'release'">
<button @click="simulShoot">模拟</button>
<button @click="simulShoot2">射箭</button>
<button @click="openAim">开瞄</button>
<button @click="closeAim">关瞄</button>
</view>
</view>
</template>
+11 -1
View File
@@ -13,7 +13,7 @@ import PointSwitcher from "@/components/PointSwitcher.vue";
import TargetCanvas from "@/components/TargetCanvas.vue";
import { MESSAGETYPES, MESSAGETYPESV2 } from "@/constants";
import { simulShootAPI } from "@/apis";
import { simulShootAPI, laserAimAPI, laserCloseAPI } from "@/apis";
import useStore from "@/store";
import { storeToRefs } from "pinia";
const store = useStore();
@@ -372,6 +372,14 @@ const simulShoot2 = async () => {
}
};
const openAim = async () => {
await laserAimAPI();
};
const closeAim = async () => {
await laserCloseAPI();
};
const env = computed(() => {
const accountInfo = uni.getAccountInfoSync();
return accountInfo.miniProgram.envVersion;
@@ -557,6 +565,8 @@ onBeforeUnmount(() => {
<view class="simul" v-if="env !== 'release'">
<button @click="simulShoot">模拟</button>
<button @click="simulShoot2">射箭</button>
<button @click="openAim">开瞄</button>
<button @click="closeAim">关瞄</button>
</view>
</view>
</template>
+156 -26
View File
@@ -14,6 +14,7 @@ import audioManager from "@/audioManager";
import {
createPractiseV2API,
getCurrentPractiseAPI,
startPractiseAPI,
endPractiseAPI,
getPractiseAPI,
@@ -21,6 +22,7 @@ import {
import {
connectMatchWebSocket,
closeMatchWebSocket,
setMatchAppHideResumable,
MATCH_WS_PRACTICE_SYNC_EVENT,
MATCH_WS_STATE_EVENT,
} from "@/matchWebsocket";
@@ -76,6 +78,10 @@ const stopCompleted = ref(false);
const stopInFlight = ref(false);
const exiting = ref(false);
const hiddenWhileActive = ref(false);
const resumeInFlight = ref(false);
const foregroundResumeSyncPending = ref(false);
const pageVisible = ref(true);
const appHideResumable = ref(false);
const connectionClosed = ref(true);
let stopPracticeTask = null;
let practiceSyncTimer = null;
@@ -108,6 +114,12 @@ const isVip = computed(
() => practiceInfo.value.vip === true && !isSvip.value
);
const setPracticeAppHideResumable = (enabled) => {
const nextValue = enabled === true;
appHideResumable.value = nextValue;
setMatchAppHideResumable(nextValue);
};
const trainingType = computed(
() => practiceInfo.value.trainingType || trainingParams.value.type || ""
);
@@ -561,6 +573,19 @@ const startPracticeSyncTimer = () => {
practiceSyncTimer = null;
if (!waitingPracticeSync) return;
waitingPracticeSync = false;
if (foregroundResumeSyncPending.value) {
foregroundResumeSyncPending.value = false;
closePracticeConnection("training-practice-resume-timeout", {
sendLeave: false,
});
uni.showToast({
title: "训练重连失败,请重试",
icon: "none",
});
return;
}
if (pageStage.value !== pageStages.LOADING) return;
uni.showToast({
@@ -597,7 +622,9 @@ const onPracticeInfoSync = (payload = {}) => {
const shouldShowDistance =
waitingPracticeSync && pageStage.value === pageStages.LOADING;
const resumedFromForeground = foregroundResumeSyncPending.value;
cancelPracticeSyncWait();
foregroundResumeSyncPending.value = false;
invalidateShotPresentations();
// 14 是完整快照,先清空旧值,避免 proto3 省略的 0 沿用上一份状态。
@@ -607,6 +634,10 @@ const onPracticeInfoSync = (payload = {}) => {
applyVisiblePrecisionTarget(practiceInfo.value);
scores.value = Array.isArray(snapshot.details) ? snapshot.details : [];
if (resumedFromForeground) {
hiddenWhileActive.value = false;
}
if (shouldShowDistance) {
start.value = false;
pageStage.value = pageStages.DISTANCE;
@@ -650,18 +681,21 @@ const clearPracticeRuntimeContext = () => {
uni.setStorageSync(trainingDifficultyStorageKey, selectionContext);
};
const closePracticeConnection = (reason) => {
const closePracticeConnection = (reason, { sendLeave = true } = {}) => {
cancelPracticeSyncWait();
if (connectionClosed.value) return;
connectionClosed.value = true;
closeMatchWebSocket({ reason });
closeMatchWebSocket({ reason, sendLeave });
};
const connectPracticeServer = () => {
const connectPracticeServer = ({
resumableOnAppHide = appHideResumable.value,
} = {}) => {
if (!practiseId.value || !String(serverAddr.value || "").trim()) {
return false;
}
appHideResumable.value = resumableOnAppHide === true;
cancelPracticeSyncWait();
closeMatchWebSocket({ reason: "training-practice-switch" });
preparePracticeSyncWait();
@@ -670,6 +704,7 @@ const connectPracticeServer = () => {
matchId: practiseId.value,
userId: user.value.id,
requestPracticeInfoOnOpen: true,
appHideResumable: appHideResumable.value,
});
connectionClosed.value = false;
return true;
@@ -703,6 +738,97 @@ const stopCurrentPractice = () => {
return stopPracticeTask;
};
// 当前训练不可恢复时,始终使用页面本地的练习 ID 停止。
const stopEndedCurrentPracticeAndExit = async () => {
hiddenWhileActive.value = false;
foregroundResumeSyncPending.value = false;
setPracticeAppHideResumable(false);
exiting.value = true;
try {
await stopCurrentPractice();
} finally {
closePracticeConnection("training-practice-current-missing");
clearPracticeRuntimeContext();
uni.showToast({
title: "训练已结束,请重新进入",
icon: "none",
});
uni.navigateBack();
}
};
// 回到前台后先获取最新比赛服地址,再通过 type 5/type 14 恢复完整快照。
const resumeCurrentPractice = async () => {
if (
resumeInFlight.value ||
!hiddenWhileActive.value ||
!appHideResumable.value ||
exiting.value
) {
return;
}
resumeInFlight.value = true;
try {
let currentPractice;
try {
currentPractice = await getCurrentPractiseAPI();
} catch (error) {
if (!pageVisible.value || exiting.value) return;
console.error("get current practice failed", error);
await stopEndedCurrentPracticeAndExit();
return;
}
console.log(1111111111111111111111, currentPractice)
if (
!pageVisible.value ||
!hiddenWhileActive.value ||
exiting.value
) {
return;
}
const latestPracticeId = currentPractice?.id;
const latestServerAddr = String(
currentPractice?.serverAddr || ""
).trim();
if (
currentPractice === null ||
!latestPracticeId ||
!latestServerAddr
) {
await stopEndedCurrentPracticeAndExit();
return;
}
practiseId.value = latestPracticeId;
serverAddr.value = latestServerAddr;
updateTrainingContext({
id: latestPracticeId,
serverAddr: latestServerAddr,
});
foregroundResumeSyncPending.value = true;
setPracticeAppHideResumable(true);
if (!connectPracticeServer({ resumableOnAppHide: true })) {
foregroundResumeSyncPending.value = false;
await stopEndedCurrentPracticeAndExit();
}
} catch (error) {
foregroundResumeSyncPending.value = false;
if (!pageVisible.value || exiting.value) return;
console.error("training practice resume failed", error);
uni.showToast({
title: "训练重连失败,请重试",
icon: "none",
});
} finally {
resumeInFlight.value = false;
}
};
const createPractice = async () => {
const trainingType = trainingParams.value.type;
const difficultyLevel = trainingParams.value.difficulty;
@@ -740,7 +866,7 @@ const createPractice = async () => {
stopInFlight.value = false;
stopPracticeTask = null;
updateTrainingContext(result);
connectPracticeServer();
connectPracticeServer({ resumableOnAppHide: true });
return result;
};
@@ -839,10 +965,12 @@ const onReady = async () => {
shotEffectToken.value = 0;
start.value = true;
pageStage.value = pageStages.SHOOTING;
setPracticeAppHideResumable(true);
audioManager.play("练习开始");
} catch (error) {
start.value = false;
pageStage.value = pageStages.DISTANCE;
setPracticeAppHideResumable(true);
throw error;
}
};
@@ -851,6 +979,8 @@ const onTimeLimitReached = () => {
if (!hasTimeLimit.value || !isShootingStage.value) return;
// 本地倒计时只负责停止射击展示,最终结算以 PRACTICE_END 为准。
start.value = false;
hiddenWhileActive.value = false;
setPracticeAppHideResumable(false);
};
const enterPracticeResult = (result = {}) => {
@@ -859,6 +989,7 @@ const enterPracticeResult = (result = {}) => {
// 正常结算不调用 stop,只清理上下文并断开比赛服连接。
practiceEnded.value = true;
setPracticeAppHideResumable(false);
invalidateShotPresentations();
clearPracticeRuntimeContext();
closePracticeConnection("training-practice-result");
@@ -872,6 +1003,7 @@ const onOver = async () => {
clearHighlightTestTimer();
pageStage.value = pageStages.LOADING;
start.value = false;
setPracticeAppHideResumable(false);
try {
const apiResult = (await getPractiseAPI(practiseId.value)) || {};
@@ -885,6 +1017,7 @@ const onOver = async () => {
}
start.value = true;
pageStage.value = pageStages.SHOOTING;
setPracticeAppHideResumable(true);
throw error;
}
};
@@ -945,6 +1078,7 @@ async function onReceiveMessage(msg) {
};
}
practiceEnded.value = true;
setPracticeAppHideResumable(false);
invalidateShotPresentations();
clearPracticeRuntimeContext();
// setTimeout(onOver, 1500);
@@ -955,6 +1089,7 @@ function onComplete() {
pageStage.value = pageStages.LOADING;
start.value = false;
practiceEnded.value = true;
setPracticeAppHideResumable(false);
invalidateShotPresentations();
clearPracticeRuntimeContext();
closePracticeConnection("training-practice-complete");
@@ -964,6 +1099,7 @@ function onComplete() {
async function onRetry() {
pageStage.value = pageStages.LOADING;
setPracticeAppHideResumable(false);
clearHighlightTestTimer();
useHighlightTest.value = false;
practiseId.value = "";
@@ -1006,6 +1142,7 @@ const updateSound = () => {
const exitPractice = async () => {
if (exiting.value) return;
exiting.value = true;
setPracticeAppHideResumable(false);
invalidateShotPresentations();
try {
@@ -1018,36 +1155,28 @@ const exitPractice = async () => {
};
onHide(() => {
invalidateShotPresentations();
// 小程序被切到后台时尽早通知后端,作为杀进程前的尽力兜底。
pageVisible.value = false;
if (
!exiting.value &&
practiseId.value &&
!practiceEnded.value &&
!stopCompleted.value
!appHideResumable.value ||
exiting.value ||
!practiseId.value ||
practiceEnded.value ||
stopCompleted.value
) {
hiddenWhileActive.value = true;
clearPracticeRuntimeContext();
void stopCurrentPractice();
return;
}
closePracticeConnection("training-practice-hide");
hiddenWhileActive.value = true;
invalidateShotPresentations();
});
onShow(async () => {
if (!hiddenWhileActive.value || exiting.value) return;
hiddenWhileActive.value = false;
await stopCurrentPractice();
clearPracticeRuntimeContext();
exiting.value = true;
uni.showToast({
title: "训练已结束,请重新进入",
icon: "none",
});
uni.navigateBack();
pageVisible.value = true;
await resumeCurrentPractice();
});
onUnload(() => {
setPracticeAppHideResumable(false);
invalidateShotPresentations();
clearPracticeRuntimeContext();
void stopCurrentPractice();
@@ -1064,7 +1193,7 @@ onMounted(() => {
uni.$on(MATCH_WS_STATE_EVENT, onMatchSocketState);
uni.$on("share-image", onClickShare);
uni.$on("audioEnded", onAudioEnded);
if (!connectPracticeServer()) {
if (!connectPracticeServer({ resumableOnAppHide: true })) {
uni.showToast({
title: "练习连接信息异常,请重试",
icon: "none",
@@ -1076,6 +1205,7 @@ onMounted(() => {
});
onBeforeUnmount(() => {
setPracticeAppHideResumable(false);
invalidateShotPresentations();
clearPracticeRuntimeContext();
void stopCurrentPractice();
+1 -1
View File
@@ -24,7 +24,7 @@ function createWebSocket(token, onMessage) {
switch (envVersion) {
case "develop": // 开发版
// url = "ws://192.168.1.5:8000/socket";
// url = "ws://192.168.1.2:8000/socket";
url = "wss://apitest.shelingxingqiu.com/socket";
break;
case "trial": // 体验版