Files
shoot-miniprograms/src/pages/training/difficulty.vue
T

905 lines
24 KiB
Vue

<script setup>
import { computed, nextTick, ref } from "vue";
import { onHide, onLoad, onShow } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import TrainingDifficultyBadge from "./components/TrainingDifficultyBadge.vue";
import TrainingDifficultyPreviewCard from "./components/TrainingDifficultyPreviewCard.vue";
import TrainingDifficultyStartButton from "./components/TrainingDifficultyStartButton.vue";
import audioManager from "@/audioManager";
import {
createPractiseV2API,
endPractiseAPI,
getTrainingDifficultyListAPI,
} from "@/apis";
// 难度页接口数据源:
// 1. 接口:GET /training/difficulty/list?type=base/endurance/precision/rhythm
// 2. 当前进度:接口 user_levels / list.completed,路由参数可覆盖选中难度
const trainingDifficultyStorageKey = "training-selection";
const defaultTrainingType = "precision";
const defaultUnlockedDifficultyId = "lv1";
const trainingTypeMetaMap = {
base: {
key: "base",
title: "基础训练",
},
endurance: {
key: "endurance",
title: "耐力训练",
},
precision: {
key: "precision",
title: "精准训练",
},
rhythm: {
key: "rhythm",
title: "节奏训练",
},
};
const routeModeTypeMap = {
basic: "base",
base: "base",
endurance: "endurance",
precision: "precision",
rhythm: "rhythm",
};
const defaultTargetType = 1;
const resolveTrainingType = (mode) => {
const normalizedMode = String(mode || "").toLowerCase();
return routeModeTypeMap[normalizedMode] || defaultTrainingType;
};
const createDifficultyId = (level) => `lv${level}`;
const toNumber = (value, fallback = 0) => {
const numberValue = Number(value);
return Number.isFinite(numberValue) ? numberValue : fallback;
};
const clampProgress = (value) => {
return Math.min(Math.max(value, 0), 100);
};
const getDifficultyProgress = (item = {}) => {
const completedCnt = toNumber(item.completed_cnt);
const promoteCnt = toNumber(item.promote_cnt);
if (item.completed) {
return 100;
}
if (promoteCnt <= 0) {
return 0;
}
return clampProgress(Math.round((completedCnt / promoteCnt) * 100));
};
const checkDifficultyCompleted = (item = {}) => {
const completedCnt = toNumber(item.completed_cnt);
const promoteCnt = toNumber(item.promote_cnt);
return Boolean(item.completed) || (promoteCnt > 0 && completedCnt >= promoteCnt);
};
const getDifficultyModeText = (mode) => {
return Number(mode) === 1 ? "随机区域+指定环数" : "随机区域命中";
};
const createEmptyModeConfig = (type = defaultTrainingType) => {
const meta = trainingTypeMetaMap[type] || trainingTypeMetaMap[defaultTrainingType];
return {
key: meta.key,
title: meta.title,
nodes: [],
details: {},
activeDifficultyId: defaultUnlockedDifficultyId,
progressMap: {},
};
};
const createDifficultySummary = (item = {}) => {
const desc = String(item.desc || "").trim();
const type = item.type;
const arrows = toNumber(item.arrows);
const timeLimit = toNumber(item.time_limit);
const hitReq = toNumber(item.hit_req);
const totalReq = toNumber(item.total_req);
const blocks = toNumber(item.blocks);
const promoteCnt = toNumber(item.promote_cnt);
const timeText = timeLimit > 0 ? `${timeLimit}秒内完成` : "不限时完成";
const promoteText = promoteCnt > 0 ? `完成${promoteCnt}次晋级` : "";
const summaryMap = {
base: [
desc || (hitReq > 0 ? `每箭命中${hitReq}环以上` : "上靶即可"),
[`${arrows}箭`, promoteText].filter(Boolean).join(" · "),
],
endurance: [
desc || `${timeText}${arrows}箭`,
[`累计${totalReq}环`, promoteText].filter(Boolean).join(" · "),
],
precision: [
desc || `命中${blocks}个指定区域`,
[
`${arrows}箭`,
timeText,
getDifficultyModeText(item.mode),
promoteText,
]
.filter(Boolean)
.join(" · "),
],
rhythm: [
desc || `间隔${timeLimit}秒射击`,
[
`${arrows}箭`,
hitReq > 0 ? `每箭${hitReq}环以上` : "上靶即可",
getDifficultyModeText(item.mode),
promoteText,
]
.filter(Boolean)
.join(" · "),
],
};
return (summaryMap[type] || [desc]).filter(Boolean);
};
const normalizeTrainingDifficultyConfig = (result, type) => {
const meta = trainingTypeMetaMap[type] || trainingTypeMetaMap[defaultTrainingType];
const list = Array.isArray(result?.list) ? result.list : [];
const rawItems = list.filter((item) => !item?.type || item.type === meta.key);
const difficultyItems = rawItems
.map((item) => {
const level = toNumber(item?.difficulty);
if (level <= 0) {
return null;
}
const id = createDifficultyId(level);
const label = `Lv${level}`;
return {
...item,
recordId: item.id,
completedCnt: toNumber(item.completed_cnt),
promoteCnt: toNumber(item.promote_cnt),
id,
level,
label,
title: `${label}难度`,
summary: createDifficultySummary(item),
startText: "开始",
targetPaperType: "20CM全环靶",
};
})
.filter(Boolean)
.sort((first, second) => first.level - second.level);
const maxLevel = difficultyItems.reduce(
(currentMax, item) => Math.max(currentMax, item.level),
0
);
const completedLevelFromList = difficultyItems.reduce((currentMax, item) => {
return checkDifficultyCompleted(item)
? Math.max(currentMax, item.level)
: currentMax;
}, 0);
const userCompletedLevel = toNumber(result?.user_levels?.[meta.key]);
const highestCompletedLevel = userCompletedLevel || completedLevelFromList;
const unlockedLevel = maxLevel
? Math.min(Math.max(highestCompletedLevel + 1, 1), maxLevel)
: 1;
return {
key: meta.key,
title: meta.title,
nodes: difficultyItems.map((item) => ({
id: item.id,
label: item.label,
})),
details: Object.fromEntries(
difficultyItems.map((item) => [item.id, item])
),
activeDifficultyId: createDifficultyId(unlockedLevel),
progressMap: Object.fromEntries(
difficultyItems.map((item) => [item.id, getDifficultyProgress(item)])
),
};
};
// 难度轴布局参数,节点按“等级越低越靠下”的方式排列。
const nodesLayout = {
viewportHeightRpx: 1020,
topPaddingRpx: 136,
bottomPaddingRpx: 144,
verticalGapRpx: 188,
anchorOffsetRpx: 796,
horizontalPatternRpx: [388, 232, 516, 258, 458, 304],
nearHorizontalDistanceRpx: 170,
extraGapScale: 0.5,
};
const emptyDifficulty = {
id: "",
label: "",
title: "",
summary: [],
startText: "开始",
targetPaperType: "",
};
// 页面基础状态
const pageConfig = ref(createEmptyModeConfig(defaultTrainingType));
const unlockedDifficultyId = ref(defaultUnlockedDifficultyId);
const selectedDifficultyId = ref(defaultUnlockedDifficultyId);
const nodesScrollTop = ref(0);
const nodesScrollWithAnimation = ref(false);
const routeOptions = ref({});
const shouldRefreshOnShow = ref(false);
const creatingPractice = ref(false);
let pageStateRequestGeneration = 0;
const difficultyProgressMap = computed(() => {
return pageConfig.value?.progressMap || {};
});
const clamp = (value, min, max) => {
return Math.min(Math.max(value, min), max);
};
// 从 lv1 / lv20 这类 id 中提取等级数值,统一用于排序、解锁判断和进度比较。
const getDifficultyLevel = (difficultyId = "") => {
const matched = String(difficultyId).match(/\d+/);
return matched ? Number(matched[0]) : 0;
};
// 合并节点基础信息和难度详情,并统一按等级升序整理。
const createDifficultyNodes = (config) => {
const details = config?.details || {};
const rawNodes = Array.isArray(config?.nodes) ? config.nodes : [];
const nodeMap = new Map(rawNodes.map((node) => [node.id, node]));
const difficultyIds = new Set([
...rawNodes.map((node) => node.id),
...Object.keys(details),
]);
return Array.from(difficultyIds)
.map((difficultyId) => {
const level = getDifficultyLevel(difficultyId);
const node = nodeMap.get(difficultyId) || {};
const detail = details[difficultyId] || {};
const label = node.label || detail.label || `Lv${level || ""}`;
return {
...node,
...detail,
id: difficultyId,
level,
label,
title: detail.title || `${label}难度`,
summary: Array.isArray(detail.summary) ? detail.summary : [],
startText: detail.startText || "开始",
targetPaperType: detail.targetPaperType || "",
};
})
.filter((node) => node.id && node.level > 0)
.sort((first, second) => first.level - second.level);
};
const findValidDifficultyId = (difficultyId, nodes) => {
return nodes.some((node) => node.id === difficultyId) ? difficultyId : "";
};
const getNextDifficultyId = (difficultyId, nodes) => {
const currentLevel = getDifficultyLevel(difficultyId);
return (
nodes.find((node) => node.level === currentLevel + 1)?.id || difficultyId
);
};
// 统一解析当前最新已解锁难度:
// completedDifficultyId 优先级最高,可在完成当前难度后自动推进到下一关。
const resolveUnlockedDifficultyId = (options, config, nodes) => {
const completedDifficultyId = findValidDifficultyId(
options.completedDifficultyId,
nodes
);
if (completedDifficultyId) {
return getNextDifficultyId(completedDifficultyId, nodes);
}
return (
[
options.currentDifficultyId,
options.latestDifficultyId,
options.activeDifficultyId,
config.activeDifficultyId,
defaultUnlockedDifficultyId,
].find((difficultyId) => findValidDifficultyId(difficultyId, nodes)) ||
nodes[0]?.id ||
defaultUnlockedDifficultyId
);
};
// 如果传入的默认选中项尚未解锁,则自动回退到当前最新已解锁难度。
const resolveSelectedDifficultyId = (difficultyId, nodes, currentUnlockedId) => {
const safeDifficultyId = findValidDifficultyId(difficultyId, nodes);
if (!safeDifficultyId) {
return currentUnlockedId;
}
return getDifficultyLevel(safeDifficultyId) <=
getDifficultyLevel(currentUnlockedId)
? safeDifficultyId
: currentUnlockedId;
};
// 页面渲染使用的难度节点列表,包含纵向轨道坐标。
const difficultyNodes = computed(() => {
const nodes = createDifficultyNodes(pageConfig.value);
const leftPositions = nodes.map((_, index) => {
return nodesLayout.horizontalPatternRpx[
index % nodesLayout.horizontalPatternRpx.length
];
});
const offsetsFromBottom = [];
let accumulatedOffsetRpx = 0;
nodes.forEach((node, index) => {
if (index > 0) {
const previousLeftRpx = leftPositions[index - 1];
const currentLeftRpx = leftPositions[index];
const horizontalDistanceRpx = Math.abs(currentLeftRpx - previousLeftRpx);
const extraGapRpx =
Math.max(
0,
nodesLayout.nearHorizontalDistanceRpx - horizontalDistanceRpx
) * nodesLayout.extraGapScale;
accumulatedOffsetRpx +=
nodesLayout.verticalGapRpx + Math.round(extraGapRpx);
}
offsetsFromBottom.push(accumulatedOffsetRpx);
});
const contentHeightRpx = Math.max(
nodesLayout.viewportHeightRpx,
nodesLayout.topPaddingRpx +
nodesLayout.bottomPaddingRpx +
(offsetsFromBottom[offsetsFromBottom.length - 1] || 0)
);
return nodes.map((node, index) => {
const leftRpx = leftPositions[index];
const topRpx =
contentHeightRpx -
nodesLayout.bottomPaddingRpx -
offsetsFromBottom[index];
return {
...node,
leftRpx,
topRpx,
style: {
left: `${leftRpx}rpx`,
top: `${topRpx}rpx`,
},
};
});
});
const difficultyConnectors = computed(() => {
const nodes = difficultyNodes.value;
return nodes.slice(1).map((currentNode, index) => {
const previousNode = nodes[index];
const startX = Number(previousNode?.leftRpx || 0);
const startY = Number(previousNode?.topRpx || 0);
const endX = Number(currentNode?.leftRpx || 0);
const endY = Number(currentNode?.topRpx || 0);
const midX = (startX + endX) / 2;
const midY = (startY + endY) / 2;
const angle =
(Math.atan2(endY - startY, endX - startX) * 180) / Math.PI + 90;
return {
id: `${previousNode.id}-${currentNode.id}`,
left: `${midX}rpx`,
top: `${midY}rpx`,
transform: `translate(-50%, -50%) rotate(${angle}deg)`,
};
});
});
const nodesTrackHeightRpx = computed(() => {
const bottomNode = difficultyNodes.value[0];
if (!bottomNode) {
return nodesLayout.viewportHeightRpx;
}
return Math.max(
nodesLayout.viewportHeightRpx,
bottomNode.topRpx + nodesLayout.bottomPaddingRpx
);
});
const nodesTrackStyle = computed(() => {
return {
height: `${nodesTrackHeightRpx.value}rpx`,
};
});
const selectedDifficulty = computed(() => {
return (
difficultyNodes.value.find((node) => node.id === selectedDifficultyId.value) ||
difficultyNodes.value[0] ||
emptyDifficulty
);
});
const targetTypeImageMap = {
20: "https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/20cm.png",
40: "https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/40cm.png",
};
// 根据接口返回的 target_type 展示当前难度使用的靶纸类型。
const selectedTargetTypeImage = computed(() => {
return targetTypeImageMap[toNumber(selectedDifficulty.value?.target_type)] || "";
});
// 优先显示配置中的难度进度;没有配置时,再按已解锁等级推导完成态。
const getCompletedDifficultyProgress = (node) => {
const configuredProgress = Number(difficultyProgressMap.value[node?.id]);
if (Number.isFinite(configuredProgress) && configuredProgress > 0) {
return configuredProgress;
}
return getDifficultyLevel(node?.id) < getDifficultyLevel(unlockedDifficultyId.value)
? 100
: 0;
};
const checkDifficultyLocked = (node) => {
return getDifficultyLevel(node?.id) > getDifficultyLevel(unlockedDifficultyId.value);
};
// 根据目标难度计算 scroll-view 应滚动到的位置,顶部/底部会自动吸附边界。
const scrollToDifficulty = (difficultyId, animated = false) => {
const node = difficultyNodes.value.find((item) => item.id === difficultyId);
if (!node) {
return;
}
const maxScrollRpx = Math.max(
nodesTrackHeightRpx.value - nodesLayout.viewportHeightRpx,
0
);
const targetScrollRpx = clamp(
node.topRpx - nodesLayout.anchorOffsetRpx,
0,
maxScrollRpx
);
nodesScrollWithAnimation.value = animated;
nodesScrollTop.value = uni.upx2px(targetScrollRpx);
};
// 首次进入页面需要静默定位到默认难度;
// 定位完成后再开启滚动动画,避免第一次手动切换时丢失过渡效果。
const initScrollPosition = () => {
scrollToDifficulty(selectedDifficultyId.value, false);
nextTick(() => {
nodesScrollWithAnimation.value = true;
});
};
const normalizeRouteOptions = (options = {}) => {
const difficultyLevel = toNumber(options.difficulty);
const completedDifficultyLevel = toNumber(options.completedDifficulty);
return {
...options,
difficultyId:
options.difficultyId ||
(difficultyLevel > 0 ? createDifficultyId(difficultyLevel) : ""),
completedDifficultyId:
options.completedDifficultyId ||
(completedDifficultyLevel > 0
? createDifficultyId(completedDifficultyLevel)
: ""),
};
};
const applyPageState = (options = {}, config) => {
const safeOptions = normalizeRouteOptions(options);
const nodes = createDifficultyNodes(config);
const currentUnlockedId = resolveUnlockedDifficultyId(
safeOptions,
config,
nodes
);
pageConfig.value = config;
unlockedDifficultyId.value = currentUnlockedId;
selectedDifficultyId.value = resolveSelectedDifficultyId(
safeOptions.difficultyId,
nodes,
currentUnlockedId
);
nextTick(() => {
initScrollPosition();
});
};
const initPageState = async (options = {}, refreshOptions = {}) => {
const requestGeneration = ++pageStateRequestGeneration;
const { keepCurrent = false } = refreshOptions;
const trainingType = resolveTrainingType(options.mode);
const fallbackConfig = createEmptyModeConfig(trainingType);
if (!keepCurrent) {
pageConfig.value = fallbackConfig;
}
try {
const result = await getTrainingDifficultyListAPI(trainingType);
if (requestGeneration !== pageStateRequestGeneration) {
return;
}
applyPageState(
options,
normalizeTrainingDifficultyConfig(result, trainingType)
);
} catch (error) {
if (requestGeneration !== pageStateRequestGeneration) {
return;
}
console.log("training difficulty load failed", error);
if (!keepCurrent) {
applyPageState(options, fallbackConfig);
}
uni.showToast({
title: "训练难度加载失败",
icon: "none",
});
}
};
const cleanQueryValue = (value) => {
if (value === undefined || value === null || value === "") {
return "";
}
if (typeof value === "number" && !Number.isFinite(value)) {
return "";
}
return value;
};
const createPracticeQuery = (difficulty) => {
const trainingType = pageConfig.value.key || defaultTrainingType;
const commonQuery = {
type: trainingType,
difficultyId: difficulty.id,
difficulty: difficulty.level,
recordId: difficulty.recordId,
arrows: toNumber(difficulty.arrows, 12),
target: defaultTargetType,
};
const typedQueryMap = {
base: {
hitReq: toNumber(difficulty.hit_req),
},
endurance: {
totalReq: toNumber(difficulty.total_req),
},
precision: {
blocks: toNumber(difficulty.blocks),
mode: toNumber(difficulty.mode),
},
rhythm: {
hitReq: toNumber(difficulty.hit_req),
mode: toNumber(difficulty.mode),
},
};
return {
...commonQuery,
...(typedQueryMap[trainingType] || {}),
};
};
const createPracticeUrl = (difficulty) => {
const query = Object.entries(createPracticeQuery(difficulty))
.map(([key, value]) => [key, cleanQueryValue(value)])
.filter(([, value]) => value !== "")
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join("&");
return `/pages/training/practise-one${query ? `?${query}` : ""}`;
};
const saveTrainingContext = (practice = {}) => {
const difficulty = selectedDifficulty.value;
if (!difficulty.id) {
return;
}
uni.setStorageSync(trainingDifficultyStorageKey, {
trainingType: pageConfig.value.key,
trainingTitle: pageConfig.value.title,
difficultyId: difficulty.id,
difficultyLabel: difficulty.label,
targetType: defaultTargetType,
targetPaperType: difficulty.targetPaperType,
practiceId: practice.id || "",
serverAddr: practice.serverAddr || "",
createdAt: practice.id ? Date.now() : 0,
});
};
const navigateToPractice = (url) => {
return new Promise((resolve, reject) => {
uni.navigateTo({
url,
success: resolve,
fail: reject,
});
});
};
const stopCreatedPractice = async (id) => {
if (!id) return;
try {
await endPractiseAPI(id);
} catch (error) {
console.error("training practice cleanup failed", error);
}
};
const handleSelectDifficulty = (node) => {
if (!node?.id) {
return;
}
if (checkDifficultyLocked(node)) {
uni.showToast({
title: "难度尚未解锁",
icon: "none",
});
return;
}
audioManager.play("点击按钮");
if (node.id === selectedDifficultyId.value) {
return;
}
selectedDifficultyId.value = node.id;
nextTick(() => {
scrollToDifficulty(node.id, true);
});
};
const handleStart = async () => {
if (!selectedDifficulty.value.id || creatingPractice.value) {
return;
}
const trainingType = pageConfig.value.key || defaultTrainingType;
const difficultyLevel = selectedDifficulty.value.level;
let createdPracticeId = "";
creatingPractice.value = true;
try {
// 先由业务接口创建训练和比赛服,再把连接上下文交给目标页。
const result = await createPractiseV2API(trainingType, difficultyLevel);
createdPracticeId = result?.id || "";
if (!createdPracticeId) {
saveTrainingContext();
uni.showToast({
title: "训练创建失败,请重试",
icon: "none",
});
return;
}
if (!String(result?.serverAddr || "").trim()) {
await stopCreatedPractice(createdPracticeId);
saveTrainingContext();
uni.showToast({
title: "练习连接信息异常,请重试",
icon: "none",
});
return;
}
saveTrainingContext(result);
await navigateToPractice(createPracticeUrl(selectedDifficulty.value));
createdPracticeId = "";
} catch (error) {
await stopCreatedPractice(createdPracticeId);
saveTrainingContext();
console.error("training practice create failed", error);
if (String(error?.errMsg || "").includes("navigateTo")) {
uni.showToast({
title: "进入训练失败,请重试",
icon: "none",
});
}
} finally {
creatingPractice.value = false;
}
};
onLoad((options = {}) => {
routeOptions.value = { ...options };
void initPageState(options);
});
onHide(() => {
shouldRefreshOnShow.value = true;
});
onShow(() => {
if (!shouldRefreshOnShow.value) return;
shouldRefreshOnShow.value = false;
void initPageState(routeOptions.value, {
keepCurrent: true,
});
});
</script>
<template>
<Container
:title="pageConfig.title"
:bgType="8"
bgColor="#1c1c23"
:scroll="false"
:loading="creatingPractice"
loadingText="训练创建中..."
>
<view class="difficulty-page">
<view class="difficulty-page__nodes">
<scroll-view
class="difficulty-page__nodes-scroll"
scroll-y
enhanced
:scroll-top="nodesScrollTop"
:scroll-with-animation="nodesScrollWithAnimation"
:show-scrollbar="false"
>
<view class="difficulty-page__nodes-track" :style="nodesTrackStyle">
<image
v-for="connector in difficultyConnectors"
:key="connector.id"
class="difficulty-page__connector"
src="https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/jiantou.png"
mode="aspectFit"
:style="connector"
/>
<TrainingDifficultyBadge
v-for="node in difficultyNodes"
:key="node.id"
:node="node"
:active="node.id === selectedDifficultyId"
:locked="checkDifficultyLocked(node)"
:completedProgress="getCompletedDifficultyProgress(node)"
@click="handleSelectDifficulty"
/>
</view>
</scroll-view>
</view>
<view class="difficulty-page__preview">
<TrainingDifficultyPreviewCard
:title="selectedDifficulty.title"
:lines="selectedDifficulty.summary"
/>
<image
v-if="selectedTargetTypeImage"
class="difficulty-page__target-type"
:src="selectedTargetTypeImage"
mode="aspectFit"
/>
</view>
<view class="difficulty-page__start">
<TrainingDifficultyStartButton
:text="selectedDifficulty.startText"
@click="$clickSound(handleStart)"
/>
</view>
</view>
</Container>
</template>
<style scoped>
.difficulty-page {
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
padding: 18rpx 0 40rpx;
overflow: hidden;
}
.difficulty-page__nodes {
position: relative;
flex: 1;
min-height: 0;
z-index: 2;
margin-bottom: 8rpx;
}
.difficulty-page__nodes-scroll {
width: 100%;
height: 100%;
}
.difficulty-page__nodes-track {
position: relative;
width: 100%;
min-height: 100%;
}
.difficulty-page__connector {
position: absolute;
width: 18rpx;
height: 28rpx;
z-index: 1;
pointer-events: none;
}
.difficulty-page__preview {
position: relative;
flex: none;
z-index: 3;
width: 540rpx;
height: 172rpx;
margin: 0 auto;
}
.difficulty-page__target-type {
position: absolute;
top: 10rpx;
left: 2rpx;
z-index: 2;
width: 100rpx;
height: 74rpx;
pointer-events: none;
}
.difficulty-page__start {
position: relative;
flex: none;
z-index: 4;
width: 302rpx;
height: 190rpx;
margin: 0 auto;
top: -16rpx;
}
</style>