Files
shoot-miniprograms/src/pages/training/practise-one.vue
T
2026-08-17 10:11:28 +08:00

1627 lines
44 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 { computed, ref, onMounted, onBeforeUnmount } from "vue";
import { onHide, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import ShootProgress from "./components/ShootProgress.vue";
import BowTarget from "./components/BowTarget.vue";
import ScorePanel2 from "@/components/TrainingScorePanel.vue";
import ScoreResult from "./components/ScoreResult.vue";
import Avatar from "@/components/Avatar.vue";
import BowPower from "@/components/BowPower.vue";
import TestDistance from "./components/TestDistance.vue";
import BubbleTip from "./components/BubbleTip.vue";
import audioManager from "@/audioManager";
import {
createPractiseV2API,
getCurrentPractiseAPI,
startPractiseAPI,
endPractiseAPI,
getPractiseAPI,
getTrainingDifficultyListAPI,
} from "@/apis";
import {
connectMatchWebSocket,
closeMatchWebSocket,
setMatchAppHideResumable,
MATCH_WS_PRACTICE_SYNC_EVENT,
MATCH_WS_STATE_EVENT,
} from "@/matchWebsocket";
import { sharePractiseData } from "@/canvas";
import { wxShare, debounce, getDirectionText } from "@/util";
import { MESSAGETYPESV2, roundsName } from "@/constants";
import useStore from "@/store";
import { storeToRefs } from "pinia";
const store = useStore();
const { user } = storeToRefs(store);
const sound = ref(true);
const start = ref(false);
const pageStages = Object.freeze({
DISTANCE: "distance",
SHOOTING: "shooting",
RESULT: "result",
LOADING: "loading",
});
const pageStage = ref(pageStages.LOADING);
const scores = ref([]);
// 只在实时 ShootResult 新增一箭时递增,避免同步快照重播飞箭特效。
const shotEffectToken = ref(0);
// 可见区域每次正式提交都递增,同一区域连续刷新也能触发动效。
const precisionTargetRefreshToken = ref(0);
const defaultTotal = 12;
const defaultTargetType = 1;
const total = ref(defaultTotal);
const practiseResult = ref({});
const practiceEndSnapshot = ref({});
const hasNextDifficulty = ref(false);
const practiseId = ref("");
const showGuide = ref(false);
const tips = ref("");
const targetType = ref(defaultTargetType);
const trainingParams = ref({});
const practiceInfo = ref({});
// 服务端状态立即落到 practiceInfo,精准训练的目标区域单独延迟展示。
const visiblePrecisionTarget = ref({
randomBlock: 0,
randomRingArea: 0,
});
const trainingDifficultyStorageKey = "training-selection";
const useHighlightTest = ref(false);
const highlightTestState = ref({
blocks: 8,
randomBlock: 1,
randomRingArea: 0,
});
const highlightTestTimer = ref(null);
const serverAddr = ref("");
const practiceEnded = ref(false);
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;
let waitingPracticeSync = false;
let shotPresentationGeneration = 0;
let nextDifficultyRequest = null;
let nextDifficultyRequestGeneration = 0;
const audioWaiters = new Set();
const shotEffectWaiters = new Map();
const PRACTICE_SYNC_TIMEOUT_MS = 5000;
const SHOT_EFFECT_WAIT_TIMEOUT_MS = 1200;
const AUDIO_TIMEOUT_BASE = 3500;
const AUDIO_TIMEOUT_PER_KEY = 2600;
const AUDIO_TIMEOUT_MAX = 12000;
const RUNTIME_DIAGNOSTIC_INTERVAL_MS = 60000;
let lastRuntimeDiagnosticAt = 0;
const env = computed(() => {
try {
return uni.getAccountInfoSync().miniProgram.envVersion;
} catch (error) {
return "release";
}
});
const maybeLogRuntimeStats = () => {
if (env.value !== "develop" && env.value !== "trial") return;
const now = Date.now();
if (now - lastRuntimeDiagnosticAt < RUNTIME_DIAGNOSTIC_INTERVAL_MS) return;
lastRuntimeDiagnosticAt = now;
console.log("[training-runtime]", {
scoreCount: scores.value.length,
renderedTargetShotCount: Math.min(scores.value.length, 12),
renderedScoreCardCount: Math.min(scores.value.length, 60) + 1,
...audioManager.getRuntimeStats(),
});
};
const isDistanceStage = computed(() => pageStage.value === pageStages.DISTANCE);
const isShootingStage = computed(() => pageStage.value === pageStages.SHOOTING);
const hasPractiseResult = computed(() => !!practiseResult.value?.details);
const showResult = computed(
() => pageStage.value === pageStages.RESULT && hasPractiseResult.value
);
const isSvip = computed(() => practiceInfo.value.sVip === true);
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 || ""
);
const getPracticeNumber = (value, fallback = 0) => {
if (value === undefined || value === null || value === "") return fallback;
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : fallback;
};
const getPositiveInteger = (value) => {
const numberValue = Number(value);
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : 0;
};
const getPrecisionTargetSnapshot = (source = {}) => ({
randomBlock: getPositiveInteger(source.randomBlock),
randomRingArea: getPositiveInteger(source.randomRingArea),
});
const applyVisiblePrecisionTarget = (source = {}) => {
visiblePrecisionTarget.value = getPrecisionTargetSnapshot(source);
};
const currentDifficultyLevel = computed(
() =>
getPositiveInteger(practiseResult.value.difficultyLevel) ||
getPositiveInteger(practiseResult.value.difficulty_level) ||
getPositiveInteger(practiceInfo.value.difficultyLevel) ||
getPositiveInteger(trainingParams.value.difficulty)
);
const resetNextDifficultyState = () => {
nextDifficultyRequestGeneration += 1;
nextDifficultyRequest = null;
hasNextDifficulty.value = false;
};
const loadNextDifficultyState = (result = {}) => {
const requestGeneration = ++nextDifficultyRequestGeneration;
const currentLevel =
getPositiveInteger(result.difficultyLevel) ||
getPositiveInteger(result.difficulty_level) ||
currentDifficultyLevel.value;
const currentTrainingType = result.trainingType || trainingType.value;
hasNextDifficulty.value = false;
if (!currentTrainingType || !currentLevel) {
nextDifficultyRequest = Promise.resolve(false);
return nextDifficultyRequest;
}
nextDifficultyRequest = getTrainingDifficultyListAPI(currentTrainingType)
.then((difficultyResult) => {
const levels = Array.isArray(difficultyResult?.list)
? difficultyResult.list
.filter(
(item) => !item?.type || item.type === currentTrainingType
)
.map((item) => getPositiveInteger(item?.difficulty))
.filter(Boolean)
: [];
const maxLevel = Math.max(0, ...levels);
const highestCompletedLevel = getPositiveInteger(
difficultyResult?.user_levels?.[currentTrainingType]
);
const latestUnlockedLevel = Math.min(
highestCompletedLevel + 1,
maxLevel
);
const canAdvance =
currentLevel < maxLevel && latestUnlockedLevel > currentLevel;
if (requestGeneration === nextDifficultyRequestGeneration) {
hasNextDifficulty.value = canAdvance;
}
return canAdvance;
})
.catch((error) => {
console.log("training next difficulty load failed", error);
if (requestGeneration === nextDifficultyRequestGeneration) {
hasNextDifficulty.value = false;
}
return false;
});
return nextDifficultyRequest;
};
// time_limit 缺失或非正数都表示整局不限时。
const timeLimit = computed(() => getPositiveInteger(practiceInfo.value.timeLimit));
const hasTimeLimit = computed(() => timeLimit.value > 0);
const precisionBlocks = computed(() => {
if (useHighlightTest.value) {
return getPositiveInteger(highlightTestState.value.blocks);
}
if (trainingType.value !== "precision") return 0;
return (
getPositiveInteger(practiceInfo.value.blocks) ||
getPositiveInteger(trainingParams.value.blocks)
);
});
const precisionRandomBlock = computed(() => {
const block = getPositiveInteger(
useHighlightTest.value
? highlightTestState.value.randomBlock
: visiblePrecisionTarget.value.randomBlock
);
return block <= precisionBlocks.value ? block : 0;
});
const precisionRandomRingArea = computed(() => {
const ring = getPositiveInteger(
useHighlightTest.value
? highlightTestState.value.randomRingArea
: visiblePrecisionTarget.value.randomRingArea
);
return ring >= 1 && ring <= 10 ? ring : 0;
});
// 只展示后端进度,不在前端重复判断训练是否完成。
const trainingCopy = computed(() => {
if (trainingType.value === "base") {
const hitReq = getPracticeNumber(
practiceInfo.value.hitReq,
trainingParams.value.hitReq
);
const targetArrows = getPositiveInteger(total.value);
const arrowsLeft = Math.min(
targetArrows,
Math.max(
0,
getPracticeNumber(practiceInfo.value.arrowsLeft, targetArrows)
)
);
const completedArrows = targetArrows - arrowsLeft;
return {
inline: true,
details: [
{ text: "计时结束前需要有" },
{ text: `(${completedArrows}/${targetArrows})`, highlight: true },
{ text: "箭命中" },
{ text: `${hitReq}环`, highlight: true },
],
};
}
if (trainingType.value === "endurance") {
const targetArrows = getPracticeNumber(
practiceInfo.value.targetArrows,
total.value
);
const targetRings = getPracticeNumber(
practiceInfo.value.targetRings,
trainingParams.value.totalReq
);
const currentArrows = getPracticeNumber(practiceInfo.value.currentArrows);
const currentRings = getPracticeNumber(practiceInfo.value.currentRings);
return {
inline: true,
details: [
{ text: "计时结束前完成" },
{ text: `(${currentArrows}/${targetArrows})`, highlight: true },
{ text: "箭且累计命中" },
{ text: `(${currentRings}/${targetRings})`, highlight: true },
{ text: "环" },
],
};
}
if (trainingType.value === "precision") {
const targetArrows = getPositiveInteger(total.value);
const arrowsLeft = Math.min(
targetArrows,
Math.max(
0,
getPracticeNumber(practiceInfo.value.arrowsLeft, targetArrows)
)
);
const completedArrows = targetArrows - arrowsLeft;
return {
inline: true,
details: [
{ text: "射箭命中高亮区域需完成" },
{ text: `(${completedArrows}/${targetArrows})`, highlight: true },
{ text: "箭" },
],
};
}
return null;
});
const toRouteNumber = (value, fallback = 0) => {
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : fallback;
};
const toPositiveRouteNumber = (value, fallback) => {
const numberValue = toRouteNumber(value, fallback);
return numberValue > 0 ? numberValue : fallback;
};
const practiceInfoFields = [
"id",
"userId",
"status",
"statusText",
"startTime",
"targetType",
"vip",
"sVip",
"trainingType",
"difficultyLevel",
"hitReq",
"arrowsLeft",
"targetArrows",
"targetRings",
"currentArrows",
"currentRings",
"blocks",
"randomBlock",
"randomRingArea",
"timeLimit",
"completed",
"totalArrows",
"duration",
"averageRing",
"stability",
"maxCombo",
"totalHits",
"deltaTotalHits",
"deltaDuration",
"deltaMaxCombo",
"deltaTotalRings",
"deltaTotalArrows",
"deltaAverageRing",
"deltaStability",
"beforeExp",
"beforeLevel",
"currentExp",
"level",
"upgradeExp",
"calories",
"shootData",
"details",
];
const practiceResultFields = [
"trainingType",
"difficultyLevel",
"completed",
"totalArrows",
"duration",
"averageRing",
"stability",
"maxCombo",
"totalHits",
"currentRings",
"deltaTotalHits",
"deltaDuration",
"deltaMaxCombo",
"deltaTotalRings",
"deltaTotalArrows",
"deltaAverageRing",
"deltaStability",
"beforeExp",
"beforeLevel",
"currentExp",
"level",
"upgradeExp",
"calories",
"details",
];
const syncPracticeInfo = (message = {}) => {
const nextInfo = practiceInfoFields.reduce((result, field) => {
if (Object.prototype.hasOwnProperty.call(message, field)) {
result[field] = message[field];
}
return result;
}, {});
const isPrecisionSnapshot =
(message.type === MESSAGETYPESV2.BattleStart ||
message.type === MESSAGETYPESV2.ShootResult) &&
(message.trainingType === "precision" || trainingType.value === "precision");
if (isPrecisionSnapshot) {
// proto3 会省略数值 0;新快照未携带时必须清除上一箭的随机目标。
if (!Object.prototype.hasOwnProperty.call(message, "randomBlock")) {
nextInfo.randomBlock = 0;
}
if (!Object.prototype.hasOwnProperty.call(message, "randomRingArea")) {
nextInfo.randomRingArea = 0;
}
}
if (Object.keys(nextInfo).length === 0) return;
practiceInfo.value = {
...practiceInfo.value,
...nextInfo,
};
};
const buildShootResultAudioKeys = (message = {}) => {
const latestDetail =
Array.isArray(message.details) && message.details.length > 0
? message.details[message.details.length - 1]
: null;
// 与原 ShootProgress 保持一致:优先使用当前箭,details 仅作兼容兜底。
const arrow = message.shootData || latestDetail;
if (!arrow) return [];
if (
arrow.playerId !== undefined &&
arrow.playerId !== null &&
String(arrow.playerId) !== String(user.value?.id)
) {
return [];
}
const keys = [
arrow.ring ? `${arrow.ringX ? "X" : arrow.ring}环` : "未上靶",
];
if (arrow.angle !== null && arrow.angle !== undefined) {
keys.push(`向${getDirectionText(arrow.angle)}调整`);
}
if (arrow.threeConsecutive10Rings === true) {
keys.push("tententen");
}
return keys;
};
const playAudioKeysAndWait = (keys) => {
const audioKeys = (Array.isArray(keys) ? keys : [keys]).filter(Boolean);
if (audioKeys.length === 0) return Promise.resolve();
const expectedKey = audioKeys[audioKeys.length - 1];
const waitTime = Math.min(
AUDIO_TIMEOUT_MAX,
Math.max(AUDIO_TIMEOUT_BASE, audioKeys.length * AUDIO_TIMEOUT_PER_KEY)
);
return new Promise((resolve) => {
let settled = false;
let timer = null;
const waiter = {
expectedKey,
done: () => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
audioWaiters.delete(waiter);
resolve();
},
};
timer = setTimeout(() => {
if (typeof audioManager.recoverIfStale === "function") {
audioManager.recoverIfStale(expectedKey);
}
waiter.done();
}, waitTime);
audioWaiters.add(waiter);
try {
audioManager.play(audioKeys, false);
} catch (error) {
console.error("training shoot result audio failed", error);
waiter.done();
}
});
};
const shouldWaitForShotEffect = (shot) => {
const x = Number(shot?.x);
const y = Number(shot?.y);
return (
isSvip.value &&
Number(shot?.ring) > 0 &&
Number.isFinite(x) &&
Number.isFinite(y)
);
};
const waitForShotEffect = (token, shouldWait) => {
if (!shouldWait) return Promise.resolve();
return new Promise((resolve) => {
let settled = false;
let timer = null;
const waiter = {
done: () => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
if (shotEffectWaiters.get(token) === waiter) {
shotEffectWaiters.delete(token);
}
resolve();
},
};
timer = setTimeout(waiter.done, SHOT_EFFECT_WAIT_TIMEOUT_MS);
shotEffectWaiters.set(token, waiter);
});
};
const invalidateShotPresentations = ({ resetVisible = false } = {}) => {
shotPresentationGeneration += 1;
Array.from(audioWaiters).forEach((waiter) => waiter.done());
Array.from(shotEffectWaiters.values()).forEach((waiter) => waiter.done());
if (resetVisible) {
applyVisiblePrecisionTarget();
}
return shotPresentationGeneration;
};
const onShotEffectComplete = (payload = {}) => {
const token = Number(payload?.token ?? payload);
if (!Number.isFinite(token)) return;
shotEffectWaiters.get(token)?.done();
};
const commitPrecisionTargetAfterPresentation = async ({
generation,
target,
audioPromise,
effectPromise,
}) => {
await Promise.all([audioPromise, effectPromise]);
if (
generation !== shotPresentationGeneration ||
practiceEnded.value ||
!isShootingStage.value ||
trainingType.value !== "precision"
) {
return;
}
applyVisiblePrecisionTarget(target);
if (precisionRandomBlock.value > 0) {
precisionTargetRefreshToken.value += 1;
}
};
const createPracticeEndSnapshot = (message = {}) => {
const source = {
...practiceInfo.value,
...message,
};
const snapshot = practiceResultFields.reduce((result, field) => {
if (Object.prototype.hasOwnProperty.call(source, field)) {
result[field] = source[field];
}
return result;
}, {});
// proto3 会省略 falsePRACTICE_END 未携带 completed 时按未达标处理。
snapshot.completed = message.completed === true;
snapshot.trainingType = source.trainingType || trainingType.value;
if (Array.isArray(message.details)) {
snapshot.details = message.details;
} else if (scores.value.length > 0) {
snapshot.details = [...scores.value];
} else if (Array.isArray(practiceInfo.value.details)) {
snapshot.details = practiceInfo.value.details;
} else {
delete snapshot.details;
}
return snapshot;
};
const mergePracticeResult = (apiResult = {}) => {
const snapshot = practiceEndSnapshot.value;
const snapshotDetails = Array.isArray(snapshot.details)
? snapshot.details
: null;
const apiDetails = Array.isArray(apiResult.details) ? apiResult.details : null;
const details = snapshotDetails?.length
? snapshotDetails
: apiDetails || snapshotDetails || [...scores.value];
return {
...apiResult,
...snapshot,
details,
};
};
const clearPracticeSyncTimer = () => {
if (!practiceSyncTimer) return;
clearTimeout(practiceSyncTimer);
practiceSyncTimer = null;
};
const cancelPracticeSyncWait = () => {
waitingPracticeSync = false;
clearPracticeSyncTimer();
};
const preparePracticeSyncWait = () => {
cancelPracticeSyncWait();
waitingPracticeSync = true;
};
const startPracticeSyncTimer = () => {
clearPracticeSyncTimer();
waitingPracticeSync = true;
practiceSyncTimer = setTimeout(() => {
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({
title: "练习信息获取失败,请重试",
icon: "none",
});
setTimeout(() => {
void exitPractice();
}, 500);
}, PRACTICE_SYNC_TIMEOUT_MS);
};
const onMatchSocketState = (event = {}) => {
if (event.state !== "open") return;
if (
event.matchId &&
String(event.matchId) !== String(practiseId.value)
) {
return;
}
// 管理器会在 open 事件后立即发送 5,从这里开始计算响应超时。
startPracticeSyncTimer();
};
const onPracticeInfoSync = (payload = {}) => {
const responseMatchId = String(
payload.matchId || payload.practiceInfo?.id || ""
);
if (!responseMatchId || responseMatchId !== String(practiseId.value)) return;
const snapshot = payload.practiceInfo;
if (!snapshot || typeof snapshot !== "object") return;
const shouldShowDistance =
waitingPracticeSync && pageStage.value === pageStages.LOADING;
const resumedFromForeground = foregroundResumeSyncPending.value;
cancelPracticeSyncWait();
foregroundResumeSyncPending.value = false;
invalidateShotPresentations();
// 14 是完整快照,先清空旧值,避免 proto3 省略的 0 沿用上一份状态。
practiceInfo.value = {};
practiceEndSnapshot.value = {};
syncPracticeInfo(snapshot);
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;
}
};
// 训练在难度页创建,目标页只消费连接上下文,避免进入页面后重复创建。
const getTrainingContext = () => {
const context = uni.getStorageSync(trainingDifficultyStorageKey);
return context && typeof context === "object" ? context : {};
};
const updateTrainingContext = (practice = {}) => {
const context = getTrainingContext();
uni.setStorageSync(trainingDifficultyStorageKey, {
...context,
trainingType: trainingParams.value.type,
difficultyLevel: trainingParams.value.difficulty,
practiceId: practice.id || "",
serverAddr: practice.serverAddr || "",
createdAt: practice.id ? Date.now() : 0,
});
};
const clearPracticeRuntimeContext = () => {
const context = getTrainingContext();
if (
context.practiceId &&
practiseId.value &&
String(context.practiceId) !== String(practiseId.value)
) {
return;
}
const {
practiceId: _practiceId,
serverAddr: _serverAddr,
createdAt: _createdAt,
...selectionContext
} = context;
uni.setStorageSync(trainingDifficultyStorageKey, selectionContext);
};
const closePracticeConnection = (reason, { sendLeave = true } = {}) => {
cancelPracticeSyncWait();
if (connectionClosed.value) return;
connectionClosed.value = true;
closeMatchWebSocket({ reason, sendLeave });
};
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();
connectMatchWebSocket({
serverAddr: serverAddr.value,
matchId: practiseId.value,
userId: user.value.id,
requestPracticeInfoOnOpen: true,
appHideResumable: appHideResumable.value,
practiceEndAudioKey: "练习结束",
});
connectionClosed.value = false;
return true;
};
// 返回、切后台和页面销毁可能连续触发,复用同一个 stop 任务避免重复请求。
const stopCurrentPractice = () => {
if (
!practiseId.value ||
practiceEnded.value ||
stopCompleted.value
) {
return Promise.resolve();
}
if (stopPracticeTask) return stopPracticeTask;
stopInFlight.value = true;
stopPracticeTask = endPractiseAPI(practiseId.value)
.then(() => {
stopCompleted.value = true;
clearPracticeRuntimeContext();
})
.catch((error) => {
console.error("training practice stop failed", error);
})
.finally(() => {
stopInFlight.value = false;
stopPracticeTask = null;
});
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;
if (!trainingType || difficultyLevel <= 0) {
uni.showToast({
title: "训练参数异常,请重新选择难度",
icon: "none",
});
return null;
}
closePracticeConnection("training-practice-recreate");
const result = await createPractiseV2API(trainingType, difficultyLevel);
if (!result?.id || !String(result?.serverAddr || "").trim()) {
if (result?.id) {
try {
await endPractiseAPI(result.id);
} catch (error) {
console.error("training practice cleanup failed", error);
}
}
uni.showToast({
title: "练习连接信息异常,请重试",
icon: "none",
});
return null;
}
practiseId.value = result.id;
serverAddr.value = result.serverAddr;
practiceEnded.value = false;
stopCompleted.value = false;
stopInFlight.value = false;
stopPracticeTask = null;
updateTrainingContext(result);
connectPracticeServer({ resumableOnAppHide: true });
return result;
};
const clearHighlightTestTimer = () => {
if (highlightTestTimer.value) {
clearInterval(highlightTestTimer.value);
highlightTestTimer.value = null;
}
};
// 开发环境测试入口:依次切换 8 个顺时针区域,偶数区域只高亮指定环。
const runHighlightTest = () => {
clearHighlightTestTimer();
useHighlightTest.value = true;
practiseResult.value = {};
pageStage.value = pageStages.SHOOTING;
start.value = true;
let block = 1;
highlightTestState.value = {
blocks: 8,
randomBlock: block,
randomRingArea: 0,
};
highlightTestTimer.value = setInterval(() => {
if (block >= highlightTestState.value.blocks) {
clearHighlightTestTimer();
return;
}
block += 1;
highlightTestState.value = {
blocks: 8,
randomBlock: block,
randomRingArea: block % 2 === 0 ? Math.min(block, 10) : 0,
};
}, 1000);
};
const resetHighlightTest = () => {
clearHighlightTestTimer();
useHighlightTest.value = false;
highlightTestState.value = {
blocks: 8,
randomBlock: 1,
randomRingArea: 0,
};
scores.value = [];
};
onLoad((options = {}) => {
const trainingContext = getTrainingContext();
targetType.value = toPositiveRouteNumber(options.target, defaultTargetType);
total.value = toPositiveRouteNumber(options.arrows, defaultTotal);
trainingParams.value = {
type: options.type || trainingContext.trainingType || "",
difficultyId: options.difficultyId || "",
difficulty: toRouteNumber(
options.difficulty,
toRouteNumber(trainingContext.difficultyLevel)
),
recordId: options.recordId || "",
hitReq: toRouteNumber(options.hitReq),
totalReq: toRouteNumber(options.totalReq),
blocks: toRouteNumber(options.blocks),
mode: toRouteNumber(options.mode),
};
practiseId.value = trainingContext.practiceId || "";
serverAddr.value = trainingContext.serverAddr || "";
});
const onReady = async () => {
if (
!practiseId.value ||
practiceEnded.value ||
stopCompleted.value ||
stopInFlight.value
) {
uni.showToast({
title: "训练已结束,请重新进入",
icon: "none",
});
return;
}
pageStage.value = pageStages.LOADING;
clearHighlightTestTimer();
useHighlightTest.value = false;
practiceEndSnapshot.value = {};
invalidateShotPresentations();
try {
await startPractiseAPI(practiseId.value);
practiseResult.value = {};
scores.value = [];
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;
}
};
const onTimeLimitReached = () => {
if (!hasTimeLimit.value || !isShootingStage.value) return;
// 本地倒计时只负责停止射击展示,最终结算以 PRACTICE_END 为准。
start.value = false;
hiddenWhileActive.value = false;
setPracticeAppHideResumable(false);
};
const enterPracticeResult = (result = {}) => {
practiseResult.value = result;
if (!hasPractiseResult.value) return false;
// 正常结算不调用 stop,只清理上下文并断开比赛服连接。
practiceEnded.value = true;
setPracticeAppHideResumable(false);
invalidateShotPresentations();
clearPracticeRuntimeContext();
closePracticeConnection("training-practice-result");
pageStage.value = pageStages.RESULT;
return true;
};
const onOver = async () => {
if (!isShootingStage.value) return;
clearHighlightTestTimer();
pageStage.value = pageStages.LOADING;
start.value = false;
setPracticeAppHideResumable(false);
try {
const apiResult = (await getPractiseAPI(practiseId.value)) || {};
await (nextDifficultyRequest || loadNextDifficultyState(apiResult));
if (!enterPracticeResult(mergePracticeResult(apiResult))) {
pageStage.value = pageStages.DISTANCE;
}
} catch (error) {
if (Object.keys(practiceEndSnapshot.value).length > 0) {
await (nextDifficultyRequest || loadNextDifficultyState(practiceEndSnapshot.value));
enterPracticeResult(mergePracticeResult());
return;
}
start.value = true;
pageStage.value = pageStages.SHOOTING;
setPracticeAppHideResumable(true);
throw error;
}
};
async function onReceiveMessage(msg) {
const previousScoreLength = scores.value.length;
syncPracticeInfo(msg);
if (msg.type === MESSAGETYPESV2.BattleStart) {
invalidateShotPresentations();
applyVisiblePrecisionTarget(practiceInfo.value);
} else if (
msg.type === MESSAGETYPESV2.ShootResult &&
isShootingStage.value
) {
let hasNewShot = false;
let latestShot = null;
if (Array.isArray(msg.details)) {
scores.value = msg.details;
hasNewShot = msg.details.length === previousScoreLength + 1;
latestShot = hasNewShot ? msg.details[msg.details.length - 1] : null;
maybeLogRuntimeStats();
}
if (trainingType.value === "precision") {
const generation = invalidateShotPresentations();
const target = getPrecisionTargetSnapshot(practiceInfo.value);
const nextEffectToken = hasNewShot ? shotEffectToken.value + 1 : 0;
const effectPromise = waitForShotEffect(
nextEffectToken,
hasNewShot && shouldWaitForShotEffect(latestShot)
);
const audioPromise = playAudioKeysAndWait(
buildShootResultAudioKeys(msg)
);
if (hasNewShot) {
shotEffectToken.value = nextEffectToken;
}
void commitPrecisionTargetAfterPresentation({
generation,
target,
audioPromise,
effectPromise,
});
} else if (hasNewShot) {
shotEffectToken.value += 1;
}
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
practiceEndSnapshot.value = createPracticeEndSnapshot(msg);
void loadNextDifficultyState(practiceEndSnapshot.value);
if (
trainingType.value === "base" &&
Number(msg.status) === 3 &&
!Object.prototype.hasOwnProperty.call(msg, "arrowsLeft")
) {
practiceInfo.value = {
...practiceInfo.value,
arrowsLeft: 0,
};
}
practiceEnded.value = true;
setPracticeAppHideResumable(false);
invalidateShotPresentations();
clearPracticeRuntimeContext();
// setTimeout(onOver, 1500);
}
}
function onComplete() {
pageStage.value = pageStages.LOADING;
start.value = false;
practiceEnded.value = true;
setPracticeAppHideResumable(false);
invalidateShotPresentations();
clearPracticeRuntimeContext();
closePracticeConnection("training-practice-complete");
uni.navigateBack();
}
async function onRetry() {
pageStage.value = pageStages.LOADING;
setPracticeAppHideResumable(false);
clearHighlightTestTimer();
useHighlightTest.value = false;
resetNextDifficultyState();
practiseId.value = "";
serverAddr.value = "";
practiseResult.value = {};
practiceEndSnapshot.value = {};
practiceInfo.value = {};
invalidateShotPresentations({ resetVisible: true });
start.value = false;
scores.value = [];
shotEffectToken.value = 0;
try {
const practice = await createPractice();
if (!practice) pageStage.value = pageStages.DISTANCE;
} catch (error) {
console.error("training practice retry failed", error);
pageStage.value = pageStages.DISTANCE;
}
}
const onClickShare = debounce(async () => {
await sharePractiseData("shareCanvas", 2, user.value, practiseResult.value);
await wxShare("shareCanvas");
});
function onAudioEnded(key) {
Array.from(audioWaiters).forEach((waiter) => {
if (waiter.expectedKey === key) waiter.done();
});
if (["比赛结束", "练习结束"].includes(String(key || ""))) {
void onOver();
}
}
const updateSound = () => {
sound.value = !sound.value;
audioManager.setMuted(!sound.value);
};
const exitPractice = async () => {
if (exiting.value) return;
exiting.value = true;
setPracticeAppHideResumable(false);
invalidateShotPresentations();
try {
await stopCurrentPractice();
} finally {
closePracticeConnection("training-practice-exit");
clearPracticeRuntimeContext();
uni.navigateBack();
}
};
onHide(() => {
pageVisible.value = false;
if (
!appHideResumable.value ||
exiting.value ||
!practiseId.value ||
practiceEnded.value ||
stopCompleted.value
) {
return;
}
hiddenWhileActive.value = true;
invalidateShotPresentations();
});
onShow(async () => {
pageVisible.value = true;
await resumeCurrentPractice();
});
onUnload(() => {
setPracticeAppHideResumable(false);
invalidateShotPresentations();
clearPracticeRuntimeContext();
void stopCurrentPractice();
closePracticeConnection("training-practice-unload");
});
onMounted(() => {
// audioManager.play("第一轮");
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);
uni.$on("audioEnded", onAudioEnded);
if (!connectPracticeServer({ resumableOnAppHide: true })) {
uni.showToast({
title: "练习连接信息异常,请重试",
icon: "none",
});
setTimeout(() => {
void exitPractice();
}, 500);
}
});
onBeforeUnmount(() => {
setPracticeAppHideResumable(false);
invalidateShotPresentations();
clearPracticeRuntimeContext();
void stopCurrentPractice();
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);
uni.$off("audioEnded", onAudioEnded);
audioManager.stopAll();
clearHighlightTestTimer();
closePracticeConnection("training-practice-unmount");
});
</script>
<template>
<Container
:bgType="isDistanceStage ? 9 : 11"
:showBottom="isDistanceStage"
:scroll="!isShootingStage"
:onBack="exitPractice"
>
<view class="practise-content">
<TestDistance
v-if="isDistanceStage"
:targetType="practiceInfo.targetType"
/>
<view v-else-if="isShootingStage" class="shooting-layout">
<view class="shooting-fixed">
<ShootProgress
:start="start"
:total="timeLimit"
:countdownEnabled="hasTimeLimit"
:trainingType="trainingType"
:isVip="isVip"
:isSvip="isSvip"
:externalShootResultAudio="trainingType === 'precision'"
:onStop="onTimeLimitReached"
/>
<view class="user-row">
<!-- <Avatar :src="user.avatar" :size="35" /> -->
<BubbleTip v-if="showGuide" type="normal2">
<text>还有两场坚持</text>
<text>就是胜利!💪</text>
</BubbleTip>
<!-- <BowPower /> -->
</view>
<BowTarget
:totalRound="start ? total / 4 : 0"
:currentRound="scores.length % 3"
:scores="scores"
:isSvip="isSvip"
:shotEffectToken="shotEffectToken"
:showCrosshair="false"
:sectorCount="precisionBlocks"
:activeSector="precisionRandomBlock"
:activeRing="precisionRandomRingArea"
:highlightRefreshToken="precisionTargetRefreshToken"
stable-shot-effect
@shot-effect-complete="onShotEffectComplete"
/>
<!-- <view v-if="env !== 'release'" class="highlight-test-actions">
<button
class="highlight-test-btn"
hover-class="none"
@click="runHighlightTest"
>
扇区测试
</button>
<button
class="highlight-test-btn"
hover-class="none"
@click="resetHighlightTest"
>
重置高亮
</button>
</view> -->
<view class="sound-row">
<button class="sound-btn" hover-class="none" @click="updateSound">
<image
class="sound-icon"
:src="`/static/sound${sound ? '' : '-off'}-yellow.png`"
mode="aspectFit"
/>
</button>
</view>
<view class="sound-text-box">
<view class="bat-text-big-box">
<image
class="dao-icon"
src="https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/dao-icon.png"
mode="widthFix"
/>
<view v-if="trainingCopy" class="bat-text-box">
<view class="bat-text-small-box">
<view class="text-round-box">
<view v-if="trainingCopy.inline" class="training-copy-inline">
<text
v-for="(part, index) in trainingCopy.details"
:key="index"
:class="{ 'text2-yellow': part.highlight }"
>{{ part.text }}</text>
</view>
<view v-else>
<view class="text1">{{ trainingCopy.title }}</view>
<view class="text2">
<text
v-for="(part, index) in trainingCopy.details"
:key="index"
:class="{ 'text2-yellow': part.highlight }"
>{{ part.text }}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<scroll-view
class="score-scroll"
scroll-y
:enhanced="true"
:show-scrollbar="false"
>
<ScorePanel2
:arrows="scores"
:total="total"
:trainingType="trainingType"
/>
</scroll-view>
</view>
<ScoreResult
v-else-if="showResult"
:rowCount="6"
:total="total"
:onClose="onComplete"
:onRetry="onRetry"
:trainingType="trainingType"
:difficultyLevel="currentDifficultyLevel"
:hasNextDifficulty="hasNextDifficulty"
:result="practiseResult"
/>
<canvas class="share-canvas" id="shareCanvas" type="2d"></canvas>
</view>
<template #bottom>
<view class="btn-box">
<image
class="btn-box-bg"
src="https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/par-star.png"
mode="widthFix"
/>
<button class="btn" @click="onReady">准备好了开始练习</button>
</view>
</template>
</Container>
</template>
<style scoped>
.practise-content {
height: 100%;
min-height: 0;
}
.shooting-layout {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.shooting-fixed {
flex-shrink: 0;
}
.score-scroll {
flex: 1;
height: 0;
min-height: 0;
overflow: hidden;
}
.btn-box{
width: 488rpx;
height: 234rpx;
position: fixed;
bottom: 130rpx;
left: 50%;
transform: translateX(-50%);
}
.btn-box-bg{
width: 100%;
}
.btn{
width: 330rpx;
height: 70rpx;
line-height: 70rpx;
background: #FED847;
border-radius: 44rpx;
text-align: center;
color: #000000;
font-size: 28rpx;
font-weight: 500;
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: -36rpx;
}
.highlight-test-actions {
display: flex;
justify-content: center;
margin-top: -24rpx;
position: relative;
z-index: 10;
}
.highlight-test-btn {
width: 150rpx;
height: 48rpx;
line-height: 48rpx;
padding: 0;
border-radius: 24rpx;
background: rgba(0, 0, 0, 0.48);
color: #fed847;
font-size: 22rpx;
}
.highlight-test-btn::after {
border: none;
}
.highlight-test-btn + .highlight-test-btn {
margin-left: 16rpx;
}
.sound-row {
height: 70rpx;
padding: 0 56rpx;
display: flex;
align-items: center;
}
.sound-text-box{
height: 125rpx;
padding: 0 56rpx;
display: flex;
align-items: flex-end;
}
.sound-btn {
width: 76rpx;
height: 70rpx;
padding: 0;
margin: 0;
background: transparent;
border: none;
}
.sound-btn::after {
border: none;
}
.sound-icon {
width: 76rpx;
height: 70rpx;
}
.bat-text-big-box{
width: 100%;
position: relative;
}
.dao-icon{
width: 160rpx;
height: 125rpx;
position: absolute;
left: 0;
bottom: 0;
}
.text-round-box{
width: 100%;
}
.bat-text-box{
display: flex;
width: 100%;
}
.bat-text-small-box{
background: rgba(0, 0, 0, 0.5);
width: 100%;
min-width: 100rpx;
box-sizing: border-box;
border-radius: 16rpx 60rpx 60rpx 16rpx;
display: flex;
flex-direction: column;
justify-content: center;
padding-left: 176rpx;
height: 112rpx;
padding-right: 30rpx;
font-size: 30rpx;
color: #E7BA80;
}
.training-copy-inline {
width: 100%;
color: #FFFFFF;
font-size: 26rpx;
font-weight: 400;
line-height: 40rpx;
white-space: normal;
word-break: break-all;
}
.text1{
font-size: 30rpx;
font-weight: 400;
color: #E7BA80;
line-height: 42rpx;
}
.text2{
color: #FFFFFF;
font-size: 26rpx;
font-weight: 400;
line-height: 36rpx;
}
.text2-yellow{
font-size: 30rpx;
color: #FFD947;
font-weight: 500;
margin: 0 4rpx;
}
</style>