1636 lines
44 KiB
Vue
1636 lines
44 KiB
Vue
<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, {
|
||
getPrecisionShotAudioKeys,
|
||
getTrainingStartAudioKey,
|
||
} 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 directionText =
|
||
arrow.angle !== null && arrow.angle !== undefined
|
||
? getDirectionText(arrow.angle)
|
||
: "";
|
||
return getPrecisionShotAudioKeys(arrow, directionText);
|
||
};
|
||
|
||
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 会省略 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 (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: "练习结束",
|
||
trainingType: trainingType.value,
|
||
});
|
||
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 startAudioKey = getTrainingStartAudioKey(trainingParams.value.type);
|
||
const warmupKeys = [startAudioKey];
|
||
if (trainingParams.value.type === "precision") {
|
||
warmupKeys.push("Bingo命中目标", "未命中");
|
||
}
|
||
void audioManager.warmKeys(warmupKeys.filter(Boolean));
|
||
});
|
||
|
||
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);
|
||
// 开始接口成功即进入正式训练,直接播放对应提示,避免依赖 BattleStart 消息。
|
||
audioManager.play(
|
||
getTrainingStartAudioKey(trainingType.value, "练习开始")
|
||
);
|
||
} 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>
|