Files
shoot-miniprograms/src/pages/training/practise-one.vue
T
2026-08-25 17:36:54 +08:00

1902 lines
53 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, {
getPrecisionShotAudioKeys,
getRhythmShotAudioKeys,
getTrainingStartAudioKey,
RHYTHM_SHOOT_WINDOW_AUDIO_KEY,
} from "@/audioManager";
import {
createPractiseV2API,
getCurrentPractiseAPI,
startPractiseAPI,
endPractiseAPI,
getPractiseAPI,
getTrainingDifficultyListAPI,
} from "@/apis";
import {
connectMatchWebSocket,
closeMatchWebSocket,
requestPracticeInfoSync,
setMatchAppHideResumable,
MATCH_WS_PRACTICE_SYNC_EVENT,
MATCH_WS_STATE_EVENT,
} from "@/matchWebsocket";
import { sharePractiseData } from "@/canvas";
import { wxShare, debounce, getDirectionText, capsuleHeight } 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 practiceStarting = ref(false);
const pageStages = Object.freeze({
DISTANCE: "distance",
SHOOTING: "shooting",
RESULT: "result",
LOADING: "loading",
});
const pageStage = ref(pageStages.LOADING);
const scores = ref([]);
// 复用金币模块的标题视觉,但保留练习页默认 Header,避免影响返回按钮。
const rhythmHeaderTitleStyle = Object.freeze({
position: "fixed",
top: `${capsuleHeight}px`,
left: "50%",
width: "430rpx",
height: "50px",
display: "flex",
alignItems: "center",
justifyContent: "center",
transform: "translateX(-50%)",
color: "#e7ba80",
fontSize: "30rpx",
lineHeight: "42rpx",
fontWeight: 500,
textAlign: "center",
whiteSpace: "nowrap",
zIndex: 20,
});
// 只在实时 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({});
const rhythmServerTimestamp = ref(0);
const rhythmFallbackWindowStart = ref(0);
const rhythmFallbackTimestamp = ref(0);
const rhythmHasActiveServerAnchor = ref(false);
// 节奏训练目标箭数以服务端 total_arrows 为准;首帧缺失时用 arrows_left 初始化并保持稳定。
const rhythmTargetArrows = ref(0);
// 服务端状态立即落到 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 isRhythmTraining = computed(() => trainingType.value === "rhythm");
const rhythmRoundTime = computed(() =>
getPositiveInteger(practiceInfo.value.roundTime) ||
getPositiveInteger(trainingParams.value.roundTime)
);
const rhythmShootTime = computed(() =>
getPositiveInteger(practiceInfo.value.shootTime) ||
getPositiveInteger(trainingParams.value.shootTime)
);
const rhythmShootWindowStart = computed(() => {
if (rhythmHasActiveServerAnchor.value) {
return practiceInfo.value.shootWindowStart || 0;
}
return (
rhythmFallbackWindowStart.value ||
practiceInfo.value.shootWindowStart ||
0
);
});
const rhythmCountdownTimestamp = computed(() =>
rhythmHasActiveServerAnchor.value
? rhythmServerTimestamp.value
: rhythmFallbackTimestamp.value || rhythmServerTimestamp.value
);
const rhythmInShootWindow = computed(
() => practiceInfo.value.inShootWindow === true
);
const rhythmHitReq = computed(
() =>
getPositiveInteger(practiceInfo.value.hitReq) ||
getPositiveInteger(trainingParams.value.hitReq)
);
const initializeRhythmFirstRoundCountdown = () => {
rhythmHasActiveServerAnchor.value = false;
rhythmFallbackWindowStart.value = 0;
rhythmFallbackTimestamp.value = 0;
if (!isRhythmTraining.value) return;
const roundTime = rhythmRoundTime.value;
const shootTime = rhythmShootTime.value;
if (!roundTime || !shootTime || shootTime > roundTime) return;
const localNow = Date.now();
rhythmFallbackTimestamp.value = localNow;
// shootWindowStart 位于整轮最后 shootTime 秒的起点。
rhythmFallbackWindowStart.value =
localNow + (roundTime - shootTime) * 1000;
};
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: "箭" },
],
};
}
if (trainingType.value === "rhythm") {
const targetArrows = getPositiveInteger(rhythmTargetArrows.value);
const arrowsLeft = Math.min(
targetArrows,
Math.max(
0,
getPracticeNumber(practiceInfo.value.arrowsLeft, targetArrows)
)
);
const completedArrows = targetArrows - arrowsLeft;
const hitReq = rhythmHitReq.value;
return {
inline: true,
details: [
{ text: "在进度条读取到高亮区间时射箭命中" },
{
text: hitReq > 0 ? `${hitReq}环内` : "指定区域",
highlight: true,
},
{ 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",
"roundTime",
"shootTime",
"shootWindowStart",
"inShootWindow",
"timeLimit",
"completed",
"totalArrows",
"duration",
"averageRing",
"stability",
"maxCombo",
"totalHits",
"deltaTotalHits",
"deltaDuration",
"deltaMaxCombo",
"deltaTotalRings",
"deltaTotalArrows",
"deltaAverageRing",
"deltaStability",
"tenRingCount",
"deltaTenRingCount",
"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",
"tenRingCount",
"deltaTenRingCount",
"beforeExp",
"beforeLevel",
"currentExp",
"level",
"upgradeExp",
"calories",
"details",
];
const cacheRhythmTrainingConfig = (message = {}) => {
const messageTrainingType = String(
message.trainingType ?? message.training_type ?? trainingType.value
).trim();
if (messageTrainingType !== "rhythm") return;
const roundTime = getPositiveInteger(
message.roundTime ?? message.round_time
);
const shootTime = getPositiveInteger(
message.shootTime ?? message.shoot_time
);
const hitReq = getPositiveInteger(message.hitReq ?? message.hit_req);
const nextConfig = {};
// 固定训练配置单独缓存,避免后续部分快照清空 practiceInfo 时丢失。
if (roundTime > 0) nextConfig.roundTime = roundTime;
if (shootTime > 0) nextConfig.shootTime = shootTime;
if (hitReq > 0) nextConfig.hitReq = hitReq;
if (Object.keys(nextConfig).length === 0) return;
trainingParams.value = {
...trainingParams.value,
...nextConfig,
};
// 开始后配置才到达时补建首轮本地锚点;已有服务端锚点时不重置。
if (
start.value &&
!rhythmHasActiveServerAnchor.value &&
!rhythmFallbackWindowStart.value &&
rhythmRoundTime.value > 0 &&
rhythmShootTime.value > 0
) {
initializeRhythmFirstRoundCountdown();
}
};
const cacheRhythmTargetArrows = (message = {}) => {
const messageTrainingType = String(
message.trainingType ?? message.training_type ?? trainingType.value
).trim();
if (messageTrainingType !== "rhythm") return;
const totalArrows = getPositiveInteger(
message.totalArrows ?? message.total_arrows
);
if (totalArrows > 0) {
rhythmTargetArrows.value = totalArrows;
return;
}
// 兼容服务端首帧 total_arrows 暂为 0:只初始化一次,避免剩余数递减时分母跟着变化。
if (rhythmTargetArrows.value === 0) {
rhythmTargetArrows.value = getPositiveInteger(
message.arrowsLeft ?? message.arrows_left
);
}
};
const syncPracticeInfo = (message = {}) => {
cacheRhythmTrainingConfig(message);
cacheRhythmTargetArrows(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 buildRhythmShootResultAudioKeys = (message = {}) => {
const latestDetail =
Array.isArray(message.details) && message.details.length > 0
? message.details[message.details.length - 1]
: null;
const arrow = message.shootData || latestDetail;
if (!arrow) return [];
if (
arrow.playerId !== undefined &&
arrow.playerId !== null &&
String(arrow.playerId) !== String(user.value?.id)
) {
return [];
}
return getRhythmShotAudioKeys(arrow);
};
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;
if (
start.value &&
(snapshot.trainingType === "rhythm" || isRhythmTraining.value) &&
Number(snapshot.shootWindowStart) > 0
) {
rhythmHasActiveServerAnchor.value = true;
}
if (payload.timestamp !== undefined && payload.timestamp !== null) {
rhythmServerTimestamp.value = payload.timestamp;
}
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),
roundTime: toRouteNumber(
options.roundTime,
toRouteNumber(trainingContext.roundTime)
),
shootTime: toRouteNumber(
options.shootTime,
toRouteNumber(trainingContext.shootTime)
),
};
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命中目标", "未命中");
} else if (trainingParams.value.type === "rhythm") {
warmupKeys.push(RHYTHM_SHOOT_WINDOW_AUDIO_KEY, "Perfect", "miss");
}
void audioManager.warmKeys(warmupKeys.filter(Boolean));
});
const onReady = async () => {
if (practiceStarting.value) return;
if (
!practiseId.value ||
practiceEnded.value ||
stopCompleted.value ||
stopInFlight.value
) {
uni.showToast({
title: "训练已结束,请重新进入",
icon: "none",
});
return;
}
practiceStarting.value = true;
pageStage.value = pageStages.LOADING;
clearHighlightTestTimer();
useHighlightTest.value = false;
practiceEndSnapshot.value = {};
invalidateShotPresentations();
try {
await startPractiseAPI(practiseId.value);
practiseResult.value = {};
scores.value = [];
shotEffectToken.value = 0;
initializeRhythmFirstRoundCountdown();
start.value = true;
pageStage.value = pageStages.SHOOTING;
setPracticeAppHideResumable(true);
// 先由本地锚点保证首轮立即显示,再用最新服务端快照无感校准。
requestPracticeInfoSync();
// 开始接口成功即进入正式训练,直接播放对应提示,避免依赖 BattleStart 消息。
audioManager.play(
getTrainingStartAudioKey(trainingType.value, "练习开始")
);
} catch (error) {
start.value = false;
pageStage.value = pageStages.DISTANCE;
setPracticeAppHideResumable(true);
throw error;
} finally {
practiceStarting.value = false;
}
};
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;
const incomingRhythmWindowStart = Number(msg.shootWindowStart);
const currentRhythmWindowStart = Number(rhythmShootWindowStart.value);
const shouldSyncRhythmAnchor =
start.value &&
isRhythmTraining.value &&
incomingRhythmWindowStart > 0 &&
(!rhythmHasActiveServerAnchor.value ||
incomingRhythmWindowStart !== currentRhythmWindowStart);
// 报靶消息的时间戳包含传输延迟,同一轮内不重复校时,避免秒数回跳。
if (
shouldSyncRhythmAnchor &&
msg.timestamp !== undefined &&
msg.timestamp !== null
) {
rhythmServerTimestamp.value = msg.timestamp;
}
syncPracticeInfo(msg);
if (shouldSyncRhythmAnchor) {
rhythmHasActiveServerAnchor.value = true;
}
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 (trainingType.value === "rhythm") {
const audioKeys = buildRhythmShootResultAudioKeys(msg);
if (audioKeys.length > 0) {
audioManager.play(audioKeys, false);
}
if (hasNewShot) {
shotEffectToken.value += 1;
}
} 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 = {};
rhythmServerTimestamp.value = 0;
rhythmFallbackWindowStart.value = 0;
rhythmFallbackTimestamp.value = 0;
rhythmHasActiveServerAnchor.value = false;
rhythmTargetArrows.value = 0;
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"
:title="isShootingStage && isRhythmTraining ? '节奏训练' : ''"
:titleStyle="
isShootingStage && isRhythmTraining ? rhythmHeaderTitleStyle : {}
"
>
<view class="practise-content">
<TestDistance
v-if="isDistanceStage"
:targetType="practiceInfo.targetType"
:autoStart="true"
@passed="onReady"
/>
<view v-else-if="isShootingStage" class="shooting-layout">
<view class="shooting-fixed">
<ShootProgress
:start="start"
:total="timeLimit"
:countdownEnabled="hasTimeLimit"
:trainingType="trainingType"
:roundTime="rhythmRoundTime"
:shootTime="rhythmShootTime"
:shootWindowStart="rhythmShootWindowStart"
:inShootWindow="rhythmInShootWindow"
:serverTimestamp="rhythmCountdownTimestamp"
:hitReq="rhythmHitReq"
:isVip="isVip"
:isSvip="isSvip"
:externalShootResultAudio="
trainingType === 'precision' || trainingType === 'rhythm'
"
: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>