update:对接个人训练改版
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, ref, onMounted, onBeforeUnmount } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
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";
|
||||
@@ -13,11 +13,17 @@ import BubbleTip from "./components/BubbleTip.vue";
|
||||
import audioManager from "@/audioManager";
|
||||
|
||||
import {
|
||||
createPractiseAPI,
|
||||
createPractiseV2API,
|
||||
startPractiseAPI,
|
||||
endPractiseAPI,
|
||||
getPractiseAPI,
|
||||
} from "@/apis";
|
||||
import {
|
||||
connectMatchWebSocket,
|
||||
closeMatchWebSocket,
|
||||
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,22 +41,41 @@ const pageStages = Object.freeze({
|
||||
RESULT: "result",
|
||||
LOADING: "loading",
|
||||
});
|
||||
const pageStage = ref(pageStages.DISTANCE);
|
||||
const pageStage = ref(pageStages.LOADING);
|
||||
const scores = ref([]);
|
||||
// 只在实时 ShootResult 新增一箭时递增,避免同步快照重播飞箭特效。
|
||||
const shotEffectToken = ref(0);
|
||||
const defaultTotal = 12;
|
||||
const defaultShootTime = 120;
|
||||
const defaultTargetType = 1;
|
||||
const total = ref(defaultTotal);
|
||||
const shootTime = ref(defaultShootTime);
|
||||
const practiseResult = ref({});
|
||||
const practiceEndSnapshot = ref({});
|
||||
const practiseId = ref("");
|
||||
const showGuide = ref(false);
|
||||
const tips = ref("");
|
||||
const targetType = ref(defaultTargetType);
|
||||
const trainingParams = ref({});
|
||||
const practiceInfo = ref({});
|
||||
const trainingDifficultyStorageKey = "training-selection";
|
||||
const trainingDifficultyRefreshEvent = "training-difficulty-refresh";
|
||||
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 connectionClosed = ref(true);
|
||||
let stopPracticeTask = null;
|
||||
let practiceSyncTimer = null;
|
||||
let waitingPracticeSync = false;
|
||||
const PRACTICE_SYNC_TIMEOUT_MS = 5000;
|
||||
|
||||
const env = computed(() => {
|
||||
try {
|
||||
@@ -66,27 +91,135 @@ const hasPractiseResult = computed(() => !!practiseResult.value?.details);
|
||||
const showResult = computed(
|
||||
() => pageStage.value === pageStages.RESULT && hasPractiseResult.value
|
||||
);
|
||||
const isSvip = computed(() => practiceInfo.value.sVip === true);
|
||||
|
||||
const defaultHighlightAreas = [{ quadrant: 1, rings: [7] }];
|
||||
const trainingType = computed(
|
||||
() => practiceInfo.value.trainingType || trainingParams.value.type || ""
|
||||
);
|
||||
|
||||
// 临时高亮测试数据:第 N 项对应第 N 箭,每箭展示一个不同区域。
|
||||
const highlightTestAreas = [
|
||||
{ arrowIndex: 1, quadrant: 1, rings: [10] },
|
||||
{ arrowIndex: 2, quadrant: 2, rings: [9, 10] },
|
||||
{ arrowIndex: 3, quadrant: 3, rings: [8, 9] },
|
||||
{ arrowIndex: 4, quadrant: 4, rings: [7, 8] },
|
||||
{ arrowIndex: 5, quadrant: 1, rings: [6, 7] },
|
||||
{ arrowIndex: 6, quadrant: 2, rings: [5, 6] },
|
||||
{ arrowIndex: 7, quadrant: 3, rings: [4, 5] },
|
||||
{ arrowIndex: 8, quadrant: 4, rings: [3, 4] },
|
||||
{ arrowIndex: 9, quadrant: 1, rings: "all", scope: "sector" },
|
||||
{ arrowIndex: 10, quadrant: 2, rings: "all", scope: "sector" },
|
||||
{ arrowIndex: 11, quadrant: 3, rings: "all", scope: "sector" },
|
||||
{ arrowIndex: 12, quadrant: 4, rings: "all", scope: "sector" },
|
||||
];
|
||||
const getPracticeNumber = (value, fallback = 0) => {
|
||||
if (value === undefined || value === null || value === "") return fallback;
|
||||
const numberValue = Number(value);
|
||||
return Number.isFinite(numberValue) ? numberValue : fallback;
|
||||
};
|
||||
|
||||
const targetHighlightAreas = computed(() => {
|
||||
return useHighlightTest.value ? highlightTestAreas : defaultHighlightAreas;
|
||||
const getPositiveInteger = (value) => {
|
||||
const numberValue = Number(value);
|
||||
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : 0;
|
||||
};
|
||||
|
||||
const currentDifficultyLevel = computed(
|
||||
() =>
|
||||
getPositiveInteger(practiseResult.value.difficultyLevel) ||
|
||||
getPositiveInteger(practiseResult.value.difficulty_level) ||
|
||||
getPositiveInteger(practiceInfo.value.difficultyLevel) ||
|
||||
getPositiveInteger(trainingParams.value.difficulty)
|
||||
);
|
||||
|
||||
// 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
|
||||
: practiceInfo.value.randomBlock
|
||||
);
|
||||
return block <= precisionBlocks.value ? block : 0;
|
||||
});
|
||||
|
||||
const precisionRandomRingArea = computed(() => {
|
||||
const ring = getPositiveInteger(
|
||||
useHighlightTest.value
|
||||
? highlightTestState.value.randomRingArea
|
||||
: practiceInfo.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 arrowsLeft = getPracticeNumber(
|
||||
practiceInfo.value.arrowsLeft,
|
||||
total.value
|
||||
);
|
||||
|
||||
return {
|
||||
title: `每箭命中${hitReq}环之上`,
|
||||
details: [
|
||||
{ text: "剩余" },
|
||||
{ text: arrowsLeft, highlight: true },
|
||||
{ text: "箭达到条件" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
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 {
|
||||
title: `完成${targetArrows}箭并累计${targetRings}环`,
|
||||
details: [
|
||||
{ text: "已完成" },
|
||||
{ text: currentArrows, highlight: true },
|
||||
{ text: "箭,累计" },
|
||||
{ text: currentRings, highlight: true },
|
||||
{ text: "环" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (trainingType.value === "precision") {
|
||||
const block = precisionRandomBlock.value;
|
||||
const ring = precisionRandomRingArea.value;
|
||||
const arrowsLeft = getPracticeNumber(
|
||||
practiceInfo.value.arrowsLeft,
|
||||
total.value
|
||||
);
|
||||
const title = block
|
||||
? ring
|
||||
? `请命中区域${block}的${ring}环`
|
||||
: `请命中区域${block}`
|
||||
: "等待目标区域";
|
||||
|
||||
return {
|
||||
title,
|
||||
details: [
|
||||
{ text: "剩余" },
|
||||
{ text: arrowsLeft, highlight: true },
|
||||
{ text: "箭" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const toRouteNumber = (value, fallback = 0) => {
|
||||
@@ -99,14 +232,352 @@ const toPositiveRouteNumber = (value, fallback) => {
|
||||
return numberValue > 0 ? numberValue : fallback;
|
||||
};
|
||||
|
||||
const createPractice = async () => {
|
||||
const result = await createPractiseAPI(
|
||||
total.value,
|
||||
shootTime.value,
|
||||
targetType.value
|
||||
);
|
||||
const practiceInfoFields = [
|
||||
"id",
|
||||
"userId",
|
||||
"status",
|
||||
"statusText",
|
||||
"startTime",
|
||||
"targetType",
|
||||
"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",
|
||||
];
|
||||
|
||||
if (result) practiseId.value = result.id;
|
||||
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 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 会省略 false,PRACTICE_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 (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;
|
||||
cancelPracticeSyncWait();
|
||||
|
||||
// 14 是完整快照,先清空旧值,避免 proto3 省略的 0 沿用上一份状态。
|
||||
practiceInfo.value = {};
|
||||
practiceEndSnapshot.value = {};
|
||||
syncPracticeInfo(snapshot);
|
||||
scores.value = Array.isArray(snapshot.details) ? snapshot.details : [];
|
||||
|
||||
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) => {
|
||||
cancelPracticeSyncWait();
|
||||
if (connectionClosed.value) return;
|
||||
connectionClosed.value = true;
|
||||
closeMatchWebSocket({ reason });
|
||||
};
|
||||
|
||||
const connectPracticeServer = () => {
|
||||
if (!practiseId.value || !String(serverAddr.value || "").trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
cancelPracticeSyncWait();
|
||||
closeMatchWebSocket({ reason: "training-practice-switch" });
|
||||
preparePracticeSyncWait();
|
||||
connectMatchWebSocket({
|
||||
serverAddr: serverAddr.value,
|
||||
matchId: practiseId.value,
|
||||
userId: user.value.id,
|
||||
requestPracticeInfoOnOpen: true,
|
||||
});
|
||||
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;
|
||||
};
|
||||
|
||||
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();
|
||||
return result;
|
||||
};
|
||||
|
||||
const clearHighlightTestTimer = () => {
|
||||
@@ -116,23 +587,7 @@ const clearHighlightTestTimer = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const buildHighlightTestScore = (index) => ({
|
||||
playerId: user.value?.id,
|
||||
ring: 9,
|
||||
ringX: false,
|
||||
x: ((index % 4) - 1.5) * 2,
|
||||
y: (Math.floor(index / 4) - 1) * 2,
|
||||
angle: null,
|
||||
});
|
||||
|
||||
const setHighlightTestArrow = (arrowIndex) => {
|
||||
const completedCount = Math.max(arrowIndex - 1, 0);
|
||||
scores.value = Array.from({ length: completedCount }, (_, index) =>
|
||||
buildHighlightTestScore(index)
|
||||
);
|
||||
};
|
||||
|
||||
// 临时测试入口:自动切换第 1 到第 12 箭,让 BowTarget 按当前箭展示不同高亮。
|
||||
// 开发环境测试入口:依次切换 8 个顺时针区域,偶数区域只高亮指定环。
|
||||
const runHighlightTest = () => {
|
||||
clearHighlightTestTimer();
|
||||
useHighlightTest.value = true;
|
||||
@@ -140,50 +595,83 @@ const runHighlightTest = () => {
|
||||
pageStage.value = pageStages.SHOOTING;
|
||||
start.value = true;
|
||||
|
||||
let arrowIndex = 1;
|
||||
setHighlightTestArrow(arrowIndex);
|
||||
let block = 1;
|
||||
highlightTestState.value = {
|
||||
blocks: 8,
|
||||
randomBlock: block,
|
||||
randomRingArea: 0,
|
||||
};
|
||||
|
||||
highlightTestTimer.value = setInterval(() => {
|
||||
if (arrowIndex >= highlightTestAreas.length) {
|
||||
if (block >= highlightTestState.value.blocks) {
|
||||
clearHighlightTestTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
arrowIndex += 1;
|
||||
setHighlightTestArrow(arrowIndex);
|
||||
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);
|
||||
shootTime.value = toPositiveRouteNumber(options.time, defaultShootTime);
|
||||
trainingParams.value = {
|
||||
type: options.type || "",
|
||||
type: options.type || trainingContext.trainingType || "",
|
||||
difficultyId: options.difficultyId || "",
|
||||
difficulty: toRouteNumber(options.difficulty),
|
||||
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 = {};
|
||||
try {
|
||||
await startPractiseAPI();
|
||||
await startPractiseAPI(practiseId.value);
|
||||
practiseResult.value = {};
|
||||
scores.value = [];
|
||||
shotEffectToken.value = 0;
|
||||
start.value = true;
|
||||
pageStage.value = pageStages.SHOOTING;
|
||||
audioManager.play("练习开始");
|
||||
@@ -194,6 +682,24 @@ const onReady = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const onTimeLimitReached = () => {
|
||||
if (!hasTimeLimit.value || !isShootingStage.value) return;
|
||||
// 本地倒计时只负责停止射击展示,最终结算以 PRACTICE_END 为准。
|
||||
start.value = false;
|
||||
};
|
||||
|
||||
const enterPracticeResult = (result = {}) => {
|
||||
practiseResult.value = result;
|
||||
if (!hasPractiseResult.value) return false;
|
||||
|
||||
// 正常结算不调用 stop,只清理上下文并断开比赛服连接。
|
||||
practiceEnded.value = true;
|
||||
clearPracticeRuntimeContext();
|
||||
closePracticeConnection("training-practice-result");
|
||||
pageStage.value = pageStages.RESULT;
|
||||
return true;
|
||||
};
|
||||
|
||||
const onOver = async () => {
|
||||
if (!isShootingStage.value) return;
|
||||
|
||||
@@ -202,11 +708,15 @@ const onOver = async () => {
|
||||
start.value = false;
|
||||
|
||||
try {
|
||||
practiseResult.value = (await getPractiseAPI(practiseId.value)) || {};
|
||||
pageStage.value = hasPractiseResult.value
|
||||
? pageStages.RESULT
|
||||
: pageStages.DISTANCE;
|
||||
const apiResult = (await getPractiseAPI(practiseId.value)) || {};
|
||||
if (!enterPracticeResult(mergePracticeResult(apiResult))) {
|
||||
pageStage.value = pageStages.DISTANCE;
|
||||
}
|
||||
} catch (error) {
|
||||
if (Object.keys(practiceEndSnapshot.value).length > 0) {
|
||||
enterPracticeResult(mergePracticeResult());
|
||||
return;
|
||||
}
|
||||
start.value = true;
|
||||
pageStage.value = pageStages.SHOOTING;
|
||||
throw error;
|
||||
@@ -214,9 +724,30 @@ const onOver = async () => {
|
||||
};
|
||||
|
||||
async function onReceiveMessage(msg) {
|
||||
syncPracticeInfo(msg);
|
||||
|
||||
if (msg.type === MESSAGETYPESV2.ShootResult && isShootingStage.value) {
|
||||
scores.value = msg.details;
|
||||
if (Array.isArray(msg.details)) {
|
||||
const previousScoreLength = scores.value.length;
|
||||
scores.value = msg.details;
|
||||
if (msg.details.length === previousScoreLength + 1) {
|
||||
shotEffectToken.value += 1;
|
||||
}
|
||||
}
|
||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||
practiceEndSnapshot.value = createPracticeEndSnapshot(msg);
|
||||
if (
|
||||
trainingType.value === "base" &&
|
||||
Number(msg.status) === 3 &&
|
||||
!Object.prototype.hasOwnProperty.call(msg, "arrowsLeft")
|
||||
) {
|
||||
practiceInfo.value = {
|
||||
...practiceInfo.value,
|
||||
arrowsLeft: 0,
|
||||
};
|
||||
}
|
||||
practiceEnded.value = true;
|
||||
clearPracticeRuntimeContext();
|
||||
// setTimeout(onOver, 1500);
|
||||
}
|
||||
}
|
||||
@@ -224,6 +755,9 @@ async function onReceiveMessage(msg) {
|
||||
function onComplete() {
|
||||
pageStage.value = pageStages.LOADING;
|
||||
start.value = false;
|
||||
practiceEnded.value = true;
|
||||
clearPracticeRuntimeContext();
|
||||
closePracticeConnection("training-practice-complete");
|
||||
uni.$emit(trainingDifficultyRefreshEvent);
|
||||
uni.navigateBack();
|
||||
}
|
||||
@@ -233,12 +767,18 @@ async function onRetry() {
|
||||
clearHighlightTestTimer();
|
||||
useHighlightTest.value = false;
|
||||
practiseId.value = "";
|
||||
serverAddr.value = "";
|
||||
practiseResult.value = {};
|
||||
practiceEndSnapshot.value = {};
|
||||
practiceInfo.value = {};
|
||||
start.value = false;
|
||||
scores.value = [];
|
||||
shotEffectToken.value = 0;
|
||||
try {
|
||||
await createPractice();
|
||||
} finally {
|
||||
const practice = await createPractice();
|
||||
if (!practice) pageStage.value = pageStages.DISTANCE;
|
||||
} catch (error) {
|
||||
console.error("training practice retry failed", error);
|
||||
pageStage.value = pageStages.DISTANCE;
|
||||
}
|
||||
}
|
||||
@@ -259,28 +799,89 @@ const updateSound = () => {
|
||||
audioManager.setMuted(!sound.value);
|
||||
};
|
||||
|
||||
const exitPractice = async () => {
|
||||
if (exiting.value) return;
|
||||
exiting.value = true;
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await stopCurrentPractice();
|
||||
} finally {
|
||||
closePracticeConnection("training-practice-exit");
|
||||
clearPracticeRuntimeContext();
|
||||
uni.navigateBack();
|
||||
}
|
||||
};
|
||||
|
||||
onHide(() => {
|
||||
// 小程序被切到后台时尽早通知后端,作为杀进程前的尽力兜底。
|
||||
if (
|
||||
!exiting.value &&
|
||||
practiseId.value &&
|
||||
!practiceEnded.value &&
|
||||
!stopCompleted.value
|
||||
) {
|
||||
hiddenWhileActive.value = true;
|
||||
clearPracticeRuntimeContext();
|
||||
void stopCurrentPractice();
|
||||
}
|
||||
closePracticeConnection("training-practice-hide");
|
||||
});
|
||||
|
||||
onShow(async () => {
|
||||
if (!hiddenWhileActive.value || exiting.value) return;
|
||||
|
||||
hiddenWhileActive.value = false;
|
||||
await stopCurrentPractice();
|
||||
clearPracticeRuntimeContext();
|
||||
exiting.value = true;
|
||||
uni.showToast({
|
||||
title: "训练已结束,请重新进入",
|
||||
icon: "none",
|
||||
});
|
||||
uni.navigateBack();
|
||||
});
|
||||
|
||||
onUnload(() => {
|
||||
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);
|
||||
await createPractice();
|
||||
if (!connectPracticeServer()) {
|
||||
uni.showToast({
|
||||
title: "练习连接信息异常,请重试",
|
||||
icon: "none",
|
||||
});
|
||||
setTimeout(() => {
|
||||
void exitPractice();
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
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();
|
||||
endPractiseAPI();
|
||||
closePracticeConnection("training-practice-unmount");
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -289,14 +890,21 @@ onBeforeUnmount(() => {
|
||||
:bgType="isDistanceStage ? 9 : 11"
|
||||
:showBottom="isDistanceStage"
|
||||
:scroll="!isShootingStage"
|
||||
:onBack="exitPractice"
|
||||
>
|
||||
<view class="practise-content">
|
||||
<TestDistance v-if="isDistanceStage" />
|
||||
<TestDistance
|
||||
v-if="isDistanceStage"
|
||||
:targetType="practiceInfo.targetType"
|
||||
/>
|
||||
<view v-else-if="isShootingStage" class="shooting-layout">
|
||||
<view class="shooting-fixed">
|
||||
<ShootProgress
|
||||
:start="start"
|
||||
:onStop="onOver"
|
||||
:total="timeLimit"
|
||||
:countdownEnabled="hasTimeLimit"
|
||||
:trainingType="trainingType"
|
||||
:onStop="onTimeLimitReached"
|
||||
/>
|
||||
<view class="user-row">
|
||||
<!-- <Avatar :src="user.avatar" :size="35" /> -->
|
||||
@@ -310,8 +918,13 @@ onBeforeUnmount(() => {
|
||||
:totalRound="start ? total / 4 : 0"
|
||||
:currentRound="scores.length % 3"
|
||||
:scores="scores"
|
||||
:isSvip="isSvip"
|
||||
:shotEffectToken="shotEffectToken"
|
||||
:showCrosshair="false"
|
||||
:highlightAreas="targetHighlightAreas"
|
||||
:sectorCount="precisionBlocks"
|
||||
:activeSector="precisionRandomBlock"
|
||||
:activeRing="precisionRandomRingArea"
|
||||
:showSectorLabels="precisionBlocks > 0"
|
||||
/>
|
||||
<view v-if="env !== 'release'" class="highlight-test-actions">
|
||||
<button
|
||||
@@ -319,7 +932,7 @@ onBeforeUnmount(() => {
|
||||
hover-class="none"
|
||||
@click="runHighlightTest"
|
||||
>
|
||||
高亮测试
|
||||
扇区测试
|
||||
</button>
|
||||
<button
|
||||
class="highlight-test-btn"
|
||||
@@ -340,14 +953,20 @@ onBeforeUnmount(() => {
|
||||
<view class="bat-text-big-box">
|
||||
<image
|
||||
class="dao-icon"
|
||||
src="../../static/training-difficulty-design/dao-icon.png"
|
||||
src="./static/training-difficulty-design/dao-icon.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view class="bat-text-box">
|
||||
<view v-if="trainingCopy" class="bat-text-box">
|
||||
<view class="bat-text-small-box">
|
||||
<view class="text-round-box">
|
||||
<view class="text1">每箭命中9环之上</view>
|
||||
<view class="text2">剩余<text class="text2-yellow">3</text>箭</view>
|
||||
<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>
|
||||
@@ -369,6 +988,8 @@ onBeforeUnmount(() => {
|
||||
:total="total"
|
||||
:onClose="onComplete"
|
||||
:onRetry="onRetry"
|
||||
:trainingType="trainingType"
|
||||
:difficultyLevel="currentDifficultyLevel"
|
||||
:result="practiseResult"
|
||||
/>
|
||||
<canvas class="share-canvas" id="shareCanvas" type="2d"></canvas>
|
||||
@@ -377,7 +998,7 @@ onBeforeUnmount(() => {
|
||||
<view class="btn-box">
|
||||
<image
|
||||
class="btn-box-bg"
|
||||
src="../../static/training-difficulty-design/par-star.png"
|
||||
src="./static/training-difficulty-design/par-star.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<button class="btn" @click="onReady">准备好了,开始练习</button>
|
||||
|
||||
Reference in New Issue
Block a user