update:新增稳定训练

This commit is contained in:
2026-08-26 15:23:02 +08:00
parent 45bc324f70
commit 96b64e4187
15 changed files with 645 additions and 25 deletions
+3 -1
View File
@@ -376,9 +376,11 @@ export const readyGameAPI = (battleId) => {
});
};
export const simulShootAPI = (device_id, x, y) => {
export const simulShootAPI = (device_id, x, y, targetType = 40) => {
const data = {
device_id,
// 模拟射箭仅支持 20cm、40cm 靶纸,未传或传入无效值时默认使用 40cm。
targetType: Number(targetType) === 20 ? 20 : 40,
};
if (x !== undefined && y !== undefined) {
data.x = x;
+22
View File
@@ -1,12 +1,18 @@
export const AUDIO_INTERRUPTION_BEGIN_EVENT = "audio-interruption-begin";
export const AUDIO_INTERRUPTION_END_EVENT = "audio-interruption-end";
export const RHYTHM_SHOOT_WINDOW_AUDIO_KEY = "请射箭";
export const STABILITY_START_AUDIO_KEY =
"请在计时结束时能量条达到标记区域";
export const STABILITY_ENERGY_50_AUDIO_KEY = "加油!到达50%啦";
export const STABILITY_ENERGY_70_AUDIO_KEY =
"70%啦!还差一点,胜利就在眼前";
const TRAINING_START_AUDIO_KEY_MAP = Object.freeze({
base: "请于计时前完成指定环数对应箭量",
endurance: "请于计时前完成累计环数和箭量",
precision: "请射箭命中高亮区域",
rhythm: "请在读条推进到标记区间内射箭",
stability: STABILITY_START_AUDIO_KEY,
});
export const getTrainingStartAudioKey = (trainingType, fallback = "") =>
@@ -41,6 +47,16 @@ export const getRhythmShotAudioKeys = (shootData) => {
return [ringAudioKey, shootData.ok === true ? "Perfect" : "miss"];
};
export const getStabilityShotAudioKeys = (shootData) => {
if (!shootData || typeof shootData !== "object") return [];
const ring = Number(shootData.ring);
const ringAudioKey =
Number.isFinite(ring) && ring > 0
? `${shootData.ringX ? "X" : ring}`
: "未上靶";
return [ringAudioKey];
};
export const audioFils = {
tententen: "https://static.shelingxingqiu.com/shootmini/static/audio/tententen.mp3",
点击按钮: "https://static.shelingxingqiu.com/shootmini/static/audio/%E7%82%B9%E5%87%BB%E6%8C%89%E9%92%AE.mp3",
@@ -159,6 +175,12 @@ export const audioFils = {
"https://static.shelingxingqiu.com/attachment/2025-11-13/de7kzzllq0futwynso.mp3",
练习开始:
"https://static.shelingxingqiu.com/attachment/2025-11-14/de88w0lmmt43nnfmoi.mp3",
[STABILITY_START_AUDIO_KEY]:
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E8%AF%B7%E5%9C%A8%E8%AE%A1%E6%97%B6%E7%BB%93%E6%9D%9F%E6%97%B6%E8%83%BD%E9%87%8F%E6%9D%A1%E8%BE%BE%E5%88%B0%E6%A0%87%E8%AE%B0%E5%8C%BA%E5%9F%9F.MP3",
[STABILITY_ENERGY_50_AUDIO_KEY]:
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E5%8A%A0%E6%B2%B9%EF%BC%81%E5%88%B0%E8%BE%BE50%25%E5%95%A6.MP3",
[STABILITY_ENERGY_70_AUDIO_KEY]:
"https://static.shelingxingqiu.com/shootmini/static/audio0820/70%25%E5%95%A6%EF%BC%81%E8%BF%98%E5%B7%AE%E4%B8%80%E7%82%B9%EF%BC%8C%E8%83%9C%E5%88%A9%E5%B0%B1%E5%9C%A8%E7%9C%BC%E5%89%8D.MP3",
请于计时前完成指定环数对应箭量:
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E8%AF%B7%E4%BA%8E%E8%AE%A1%E6%97%B6%E5%89%8D%E5%AE%8C%E6%88%90%E6%8C%87%E5%AE%9A%E7%8E%AF%E6%95%B0%E5%AF%B9%E5%BA%94%E7%AE%AD%E9%87%8F.MP3",
请于计时前完成累计环数和箭量:
+3 -2
View File
@@ -24,7 +24,7 @@ const props = defineProps({
const getDisplayText = (arrow = {}) => {
if (!arrow) return "";
if (!arrow.ring) return "0";
if (!arrow.ring) return props.trainingType === "stability" ? "-" : "0";
return arrow.ringX ? "X" : String(arrow.ring);
};
@@ -33,7 +33,8 @@ const isFailed = (arrow = {}) => {
if (
props.recordMode &&
props.trainingType !== "precision" &&
props.trainingType !== "rhythm"
props.trainingType !== "rhythm" &&
props.trainingType !== "stability"
) {
return false;
}
+194
View File
@@ -17,7 +17,10 @@ import {
import {
getPrecisionShotAudioKeys,
getRhythmShotAudioKeys,
getStabilityShotAudioKeys,
getTrainingStartAudioKey,
STABILITY_ENERGY_50_AUDIO_KEY,
STABILITY_ENERGY_70_AUDIO_KEY,
} from "@/audioManager";
import {
normalizeId,
@@ -220,6 +223,13 @@ function buildPracticeSyncMessage(message) {
if (currentContext && practiceInfo.trainingType) {
currentContext.trainingType = String(practiceInfo.trainingType).trim();
}
updateStabilityEnergyContext(practiceInfo, {
fullSnapshot: true,
detectCrossing: false,
});
updateStabilityShotContext(practiceInfo, {
detectShot: false,
});
const matchId = normalizeId(
pickField(message, "matchId", "match_id") ||
practiceInfo.id ||
@@ -378,12 +388,171 @@ function getShootResultAudioKeys(shootData) {
return keys;
}
function normalizeScoreSlot(value) {
const scoreSlot = Number(value);
return Number.isFinite(scoreSlot) && scoreSlot > 0 ? scoreSlot : 0;
}
function clampEnergy(value, scoreSlot = 0) {
const energy = Number(value);
if (!Number.isFinite(energy)) return 0;
const maxEnergy = normalizeScoreSlot(scoreSlot);
return maxEnergy > 0
? Math.min(maxEnergy, Math.max(0, energy))
: Math.max(0, energy);
}
function getEnergyPercent(energy, scoreSlot) {
const maxEnergy = normalizeScoreSlot(scoreSlot);
if (!maxEnergy) return 0;
return Math.min(100, (clampEnergy(energy, maxEnergy) / maxEnergy) * 100);
}
function getStabilityArrowCount(source = {}) {
const candidates = [
Array.isArray(source.details) ? source.details.length : NaN,
Number(source.currentArrows),
Number(source.totalArrows),
].filter((value) => Number.isFinite(value) && value >= 0);
return candidates.length > 0 ? Math.max(...candidates) : null;
}
function updateStabilityShotContext(
source = {},
{ detectShot = false } = {}
) {
const trainingType = String(
source.trainingType || currentContext?.trainingType || ""
).trim();
if (trainingType !== "stability" || !currentContext) return false;
const nextArrowCount = getStabilityArrowCount(source);
if (nextArrowCount === null) return false;
const storedArrowCount = Number(currentContext.stabilityArrowCount);
const previousArrowCount = Number.isFinite(storedArrowCount)
? storedArrowCount
: 0;
const hasNewShot =
detectShot && nextArrowCount > previousArrowCount;
if (nextArrowCount >= previousArrowCount) {
currentContext.stabilityArrowCount = nextArrowCount;
}
return hasNewShot;
}
// 稳定训练的阈值语音必须和 ACK 使用同一份判定结果,避免页面播放了
// 里程碑语音但比赛服提前收到 ACK。能量跌破阈值后再次向上跨越可重复触发。
function updateStabilityEnergyContext(
source = {},
{ fullSnapshot = false, detectCrossing = false } = {}
) {
const trainingType = String(
source.trainingType || currentContext?.trainingType || ""
).trim();
if (trainingType !== "stability" || !currentContext) return "";
const hasCurrentEnergy = Object.prototype.hasOwnProperty.call(
source,
"currentEnergy"
);
if (!hasCurrentEnergy && !fullSnapshot) return "";
const previousScoreSlot = normalizeScoreSlot(
currentContext.stabilityScoreSlot
);
const nextScoreSlot =
normalizeScoreSlot(source.scoreSlot) || previousScoreSlot;
const previousEnergy = clampEnergy(
currentContext.stabilityEnergy,
previousScoreSlot
);
const nextEnergy = clampEnergy(
hasCurrentEnergy ? source.currentEnergy : 0,
nextScoreSlot
);
const previousPercent = getEnergyPercent(
previousEnergy,
previousScoreSlot || nextScoreSlot
);
const nextPercent = getEnergyPercent(nextEnergy, nextScoreSlot);
currentContext.stabilityEnergy = nextEnergy;
currentContext.stabilityScoreSlot = nextScoreSlot;
if (!detectCrossing) {
currentContext.stabilityEnergy50Armed = nextPercent < 50;
currentContext.stabilityEnergy70Armed = nextPercent < 70;
return "";
}
let energy50Armed =
typeof currentContext.stabilityEnergy50Armed === "boolean"
? currentContext.stabilityEnergy50Armed
: previousPercent < 50;
let energy70Armed =
typeof currentContext.stabilityEnergy70Armed === "boolean"
? currentContext.stabilityEnergy70Armed
: previousPercent < 70;
if (nextPercent < 50) energy50Armed = true;
if (nextPercent < 70) energy70Armed = true;
let milestoneAudioKey = "";
if (energy70Armed && nextPercent >= 70) {
milestoneAudioKey = STABILITY_ENERGY_70_AUDIO_KEY;
energy70Armed = false;
// 同一次直接跨过两个阈值时只播70%,不能在后续补播50%。
energy50Armed = false;
} else if (energy50Armed && nextPercent >= 50) {
milestoneAudioKey = STABILITY_ENERGY_50_AUDIO_KEY;
energy50Armed = false;
}
currentContext.stabilityEnergy50Armed = energy50Armed;
currentContext.stabilityEnergy70Armed = energy70Armed;
return milestoneAudioKey;
}
function getTestDistanceAudioKeys(shootData) {
if (!shootData) return [];
return [getDistanceCheckAudioKey(shootData)];
}
function attachStabilityAudioContext(message, businessMessage) {
const fullStabilitySnapshot = [
ServerMessageType.SERVER_MSG_MATCH_START,
ServerMessageType.SERVER_MSG_SHOT,
ServerMessageType.SERVER_MSG_MATCH_END,
ServerMessageType.SERVER_MSG_TIMEOUT,
ServerMessageType.SERVER_MSG_PRACTICE_END,
].includes(message.type);
const stabilityMilestoneAudioKey = updateStabilityEnergyContext(
businessMessage,
{
fullSnapshot: fullStabilitySnapshot,
detectCrossing: message.type === ServerMessageType.SERVER_MSG_SHOT,
}
);
const stabilityHasNewShot = updateStabilityShotContext(businessMessage, {
detectShot: message.type === ServerMessageType.SERVER_MSG_SHOT,
});
if (businessMessage && stabilityMilestoneAudioKey) {
businessMessage.stabilityMilestoneAudioKey = stabilityMilestoneAudioKey;
}
if (businessMessage) {
businessMessage.stabilityHasNewShot = stabilityHasNewShot;
}
}
function getAckAudioKeys(message, businessMessage) {
const stabilityMilestoneAudioKey = String(
businessMessage?.stabilityMilestoneAudioKey || ""
).trim();
const stabilityHasNewShot =
businessMessage?.stabilityHasNewShot === true;
switch (message.type) {
case ServerMessageType.SERVER_MSG_MATCH_START:
if (businessMessage?.isSecondHalfStart) return ["下半场开始"];
@@ -419,6 +588,14 @@ function getAckAudioKeys(message, businessMessage) {
if (trainingType === "rhythm") {
return getRhythmShotAudioKeys(businessMessage?.shootData);
}
if (trainingType === "stability") {
return [
...(stabilityHasNewShot
? getStabilityShotAudioKeys(businessMessage?.shootData)
: []),
stabilityMilestoneAudioKey,
].filter(Boolean);
}
return getShootResultAudioKeys(businessMessage?.shootData);
case ServerMessageType.SERVER_MSG_MATCH_END:
return ["比赛结束"];
@@ -880,6 +1057,8 @@ function handleMessage(data) {
if (businessMessage?.matchId && currentContext) {
currentContext.matchId = businessMessage.matchId;
}
// 阈值事件必须先于 ACK 判断生成,确保无 sequence 的状态推送也能播放。
attachStabilityAudioContext(message, businessMessage);
queueAckAfterAudio(message, businessMessage);
emitBusinessMessage(businessMessage);
}
@@ -991,6 +1170,21 @@ export function connectMatchWebSocket(options = {}) {
trainingType:
normalizedTrainingType ||
(isSameContext ? currentContext?.trainingType || "" : ""),
stabilityEnergy: isSameContext
? currentContext?.stabilityEnergy
: undefined,
stabilityScoreSlot: isSameContext
? currentContext?.stabilityScoreSlot
: undefined,
stabilityEnergy50Armed: isSameContext
? currentContext?.stabilityEnergy50Armed
: undefined,
stabilityEnergy70Armed: isSameContext
? currentContext?.stabilityEnergy70Armed
: undefined,
stabilityArrowCount: isSameContext
? currentContext?.stabilityArrowCount
: undefined,
meleeHalfRest: isSameContext
? currentContext?.meleeHalfRest === true
: false,
+1
View File
@@ -18,6 +18,7 @@ const trainingTypeNameMap = Object.freeze({
endurance: "耐力训练",
precision: "精准训练",
rhythm: "节奏训练",
stability: "稳定训练",
});
const trainingType = computed(() =>
+1
View File
@@ -20,6 +20,7 @@ const trainingTypeNameMap = Object.freeze({
endurance: "耐力训练",
precision: "精准训练",
rhythm: "节奏训练",
stability: "稳定训练",
});
const getTrainingTypeName = (trainingType) => {
+3 -1
View File
@@ -39,7 +39,9 @@ const getDisplayText = (arrow) => {
const isLowScore = (arrow) => {
if (!arrow) return false;
if (props.trainingType === "rhythm") return arrow.ok !== true;
if (["rhythm", "stability"].includes(props.trainingType)) {
return arrow.ok !== true;
}
if (arrow.ringX) return false;
const ring = Number(arrow.ring);
return Number.isFinite(ring) && ring < 6;
+32 -2
View File
@@ -255,6 +255,32 @@ const metricConfigs = {
deltaUnit: "次",
},
],
stability: [
{
label: "能量最高值",
valueKeys: ["maxEnergyPercent", "max_energy_percent"],
unit: "%",
deltaKeys: [
"deltaMaxEnergyPercent",
"delta_max_energy_percent",
],
deltaUnit: "%",
},
{
label: "达标箭数",
valueKeys: ["qualifiedArrows", "qualified_arrows"],
unit: "箭",
deltaKeys: ["deltaQualifiedArrows", "delta_qualified_arrows"],
deltaUnit: "箭",
},
{
label: "达标率",
valueKeys: ["qualifiedRate", "qualified_rate"],
unit: "%",
deltaKeys: ["deltaQualifiedRate", "delta_qualified_rate"],
deltaUnit: "%",
},
],
endurance: [
{
label: "完成箭数",
@@ -304,7 +330,8 @@ const metricConfigs = {
};
const resultRows = computed(() => {
const configs = metricConfigs[resultTrainingType.value] || metricConfigs.precision;
const configs =
metricConfigs[resultTrainingType.value] || metricConfigs.precision;
return configs.map((config) => {
const fallback = config.fallback ? config.fallback() : 0;
const value = readMetricNumber(config.valueKeys, fallback);
@@ -371,7 +398,10 @@ const calories = computed(
<view v-else class="stat-value">--</view>
</view>
</view>
<view v-if="resultTrainingType !== 'rhythm'" class="stat-row">
<view
v-if="!['rhythm', 'stability'].includes(resultTrainingType)"
class="stat-row"
>
<image class="stat-bg" src="https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/result-c-bg.png" mode="scaleToFill" />
<view class="stat-cell">
<text class="stat-label">消耗卡路里</text>
+112 -2
View File
@@ -73,6 +73,14 @@ const props = defineProps({
type: Number,
default: 0,
},
energyPercent: {
type: Number,
default: 0,
},
energyReqPercent: {
type: Number,
default: 0,
},
isVip: {
type: Boolean,
default: false,
@@ -137,6 +145,9 @@ let rhythmServerClockOffsetMs = 0;
let rhythmSyncGeneration = 0;
const isRhythmTraining = computed(() => props.trainingType === "rhythm");
const isStabilityTraining = computed(
() => props.trainingType === "stability"
);
const normalizePositiveInteger = (value) => {
const numberValue = Number(value);
@@ -176,6 +187,18 @@ const progressPercent = computed(() => {
return Math.max(0, Math.min(100, (remain.value / props.total) * 100));
});
const stabilityEnergyPercent = computed(() =>
Math.max(0, Math.min(100, Number(props.energyPercent) || 0))
);
const stabilityEnergyReqPercent = computed(() =>
Math.max(0, Math.min(100, Number(props.energyReqPercent) || 0))
);
const stabilityEnergyReached = computed(
() =>
stabilityEnergyReqPercent.value > 0 &&
stabilityEnergyPercent.value >= stabilityEnergyReqPercent.value
);
const rhythmMarkerPercent = computed(() => {
if (!validRhythmRoundTime.value) return 0;
return Math.max(
@@ -536,9 +559,34 @@ onBeforeUnmount(() => {
<template>
<view
v-if="show"
:class="isRhythmTraining ? 'rhythm-progress' : 'progress-card'"
:class="
isRhythmTraining
? 'rhythm-progress'
: isStabilityTraining
? 'stability-progress'
: 'progress-card'
"
>
<template v-if="isRhythmTraining">
<template v-if="isStabilityTraining">
<text class="stability-progress__time">{{ remain }}</text>
<view class="stability-progress__track">
<view
class="stability-progress__fill"
:class="{
'stability-progress__fill--reached': stabilityEnergyReached,
}"
:style="{ width: `${stabilityEnergyPercent}%` }"
/>
<view
class="stability-progress__marker"
:style="{ left: `${stabilityEnergyReqPercent}%` }"
/>
<text class="stability-progress__value">
{{ Math.round(stabilityEnergyPercent) }}%
</text>
</view>
</template>
<template v-else-if="isRhythmTraining">
<text class="rhythm-progress__title">{{ rhythmTitle }}</text>
<view class="rhythm-progress__track">
<view
@@ -758,6 +806,68 @@ onBeforeUnmount(() => {
text-align: center;
}
.stability-progress {
box-sizing: border-box;
margin: 32rpx 84rpx 0;
}
.stability-progress__time {
display: block;
margin-bottom: 20rpx;
color: #ffffff;
font-size: 34rpx;
font-weight: 500;
line-height: 48rpx;
text-align: center;
}
.stability-progress__track {
position: relative;
width: 100%;
height: 24rpx;
overflow: hidden;
border-radius: 18rpx;
background: #444444;
}
.stability-progress__fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
border-radius: 18rpx;
background: linear-gradient(90deg, #87f1df 0%, #5ba8e8 100%);
transition: width 240ms linear, background 240ms ease;
}
.stability-progress__fill--reached {
background: linear-gradient(90deg, #a5df62 0%, #61c787 100%);
}
.stability-progress__marker {
position: absolute;
top: 0;
bottom: 0;
z-index: 2;
width: 4rpx;
transform: translateX(-2rpx);
background: rgba(26, 24, 22, 0.92);
}
.stability-progress__value {
position: absolute;
inset: 0;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
color: #eefaff;
font-size: 18rpx;
line-height: 24rpx;
text-align: center;
pointer-events: none;
}
.rhythm-progress {
box-sizing: border-box;
margin: 32rpx 84rpx 0;
@@ -13,7 +13,20 @@ const props = defineProps({
});
const previewLines = computed(() => {
return props.lines.map((line) => String(line || "").trim()).filter(Boolean);
return props.lines
.map((line) => {
const rawParts = Array.isArray(line?.parts)
? line.parts
: [{ text: line }];
const parts = rawParts
.map((part) => ({
text: String(part?.text ?? part ?? "").trim(),
}))
.filter((part) => part.text);
return { parts };
})
.filter((line) => line.parts.length > 0);
});
</script>
@@ -27,13 +40,16 @@ const previewLines = computed(() => {
<view class="difficulty-preview__content">
<text class="difficulty-preview__title">{{ title }}</text>
<view class="difficulty-preview__copy">
<text
<view
v-for="(line, index) in previewLines"
:key="`${line}-${index}`"
:key="index"
class="difficulty-preview__line"
>
{{ line }}
</text>
<text
v-for="(part, partIndex) in line.parts"
:key="partIndex"
>{{ part.text }}</text>
</view>
</view>
</view>
</view>
@@ -86,6 +102,4 @@ const previewLines = computed(() => {
.difficulty-preview__line {
display: block;
}
</style>
+57 -1
View File
@@ -13,7 +13,7 @@ import {
} from "@/apis";
// 难度页接口数据源:
// 1. 接口:GET /training/difficulty/list?type=base/endurance/precision/rhythm
// 1. 接口:GET /training/difficulty/list?type=base/endurance/precision/rhythm/stability
// 2. 当前进度:接口 user_levels / list.completed,路由参数可覆盖选中难度
const trainingDifficultyStorageKey = "training-selection";
const defaultTrainingType = "precision";
@@ -35,6 +35,10 @@ const trainingTypeMetaMap = {
key: "rhythm",
title: "节奏训练",
},
stability: {
key: "stability",
title: "稳定训练",
},
};
const routeModeTypeMap = {
basic: "base",
@@ -42,6 +46,9 @@ const routeModeTypeMap = {
endurance: "endurance",
precision: "precision",
rhythm: "rhythm",
stability: "stability",
// 兼容历史上已经分享出去的 power 深链,实际创建仍统一传 stability。
power: "stability",
};
const defaultTargetType = 1;
@@ -103,6 +110,7 @@ const createDifficultySummary = (item = {}) => {
const timeLimit = toNumber(item.time_limit);
const hitReq = toNumber(item.hit_req);
const totalReq = toNumber(item.total_req);
const energyReqPercent = toNumber(item.energy_req_percent);
const shootingTimeText =
timeLimit > 0 ? `${timeLimit}秒内进行射箭` : "不限时进行射箭";
const enduranceTimeText =
@@ -129,6 +137,26 @@ const createDifficultySummary = (item = {}) => {
: "需要在指定时间节点射箭",
hitReq > 0 ? `且每箭命中${hitReq}环内` : "且每箭命中指定区域",
],
stability: [
{
parts: [
{ text: timeLimit > 0 ? "在" : "" },
{
text: timeLimit > 0 ? `${timeLimit}` : "不限时",
highlight: true,
},
{ text: timeLimit > 0 ? "内射箭,命中" : "射箭,命中" },
{ text: `${hitReq}`, highlight: true },
{ text: "可获得能量" },
],
},
{
parts: [
{ text: "计时结束能量需要大于" },
{ text: `${energyReqPercent}%`, highlight: true },
],
},
],
};
return (summaryMap[type] || [desc]).filter(Boolean);
@@ -604,6 +632,21 @@ const createPracticeQuery = (difficulty) => {
roundTime: toNumber(difficulty.round_time ?? difficulty.roundTime),
shootTime: toNumber(difficulty.shoot_time ?? difficulty.shootTime),
},
stability: {
hitReq: toNumber(difficulty.hit_req),
scoreSlot: toNumber(
difficulty.score_slot ?? difficulty.scoreSlot
),
energyPerHit: toNumber(
difficulty.energy_per_hit ?? difficulty.energyPerHit
),
energyCostPerSec: toNumber(
difficulty.energy_cost_per_sec ?? difficulty.energyCostPerSec
),
energyReqPercent: toNumber(
difficulty.energy_req_percent ?? difficulty.energyReqPercent
),
},
};
return {
@@ -638,6 +681,19 @@ const saveTrainingContext = (practice = {}) => {
targetPaperType: difficulty.targetPaperType,
roundTime: toNumber(difficulty.round_time ?? difficulty.roundTime),
shootTime: toNumber(difficulty.shoot_time ?? difficulty.shootTime),
hitReq: toNumber(difficulty.hit_req ?? difficulty.hitReq),
scoreSlot: toNumber(
difficulty.score_slot ?? difficulty.scoreSlot
),
energyPerHit: toNumber(
difficulty.energy_per_hit ?? difficulty.energyPerHit
),
energyCostPerSec: toNumber(
difficulty.energy_cost_per_sec ?? difficulty.energyCostPerSec
),
energyReqPercent: toNumber(
difficulty.energy_req_percent ?? difficulty.energyReqPercent
),
practiceId: practice.id || "",
serverAddr: practice.serverAddr || "",
createdAt: practice.id ? Date.now() : 0,
+2 -2
View File
@@ -15,9 +15,9 @@ const trainingModeRouteMap = {
endurance: "endurance",
precision: "precision",
rhythm: "rhythm",
stability: "power",
stability: "stability",
};
const unavailableTrainingIds = new Set(["stability"]);
const unavailableTrainingIds = new Set();
// 训练项目卡片右侧主图标。
const trainingModeIconMap = {
base_bow:
+185 -4
View File
@@ -13,8 +13,11 @@ import BubbleTip from "./components/BubbleTip.vue";
import audioManager, {
getPrecisionShotAudioKeys,
getRhythmShotAudioKeys,
getStabilityShotAudioKeys,
getTrainingStartAudioKey,
RHYTHM_SHOOT_WINDOW_AUDIO_KEY,
STABILITY_ENERGY_50_AUDIO_KEY,
STABILITY_ENERGY_70_AUDIO_KEY,
} from "@/audioManager";
import {
@@ -265,6 +268,9 @@ const loadNextDifficultyState = (result = {}) => {
const timeLimit = computed(() => getPositiveInteger(practiceInfo.value.timeLimit));
const hasTimeLimit = computed(() => timeLimit.value > 0);
const isRhythmTraining = computed(() => trainingType.value === "rhythm");
const isStabilityTraining = computed(
() => trainingType.value === "stability"
);
const rhythmRoundTime = computed(() =>
getPositiveInteger(practiceInfo.value.roundTime) ||
getPositiveInteger(trainingParams.value.roundTime)
@@ -297,6 +303,53 @@ const rhythmHitReq = computed(
getPositiveInteger(trainingParams.value.hitReq)
);
const stabilityEnergyPerHit = computed(() =>
Math.max(
0,
getPracticeNumber(
practiceInfo.value.energyPerHit,
trainingParams.value.energyPerHit
)
)
);
const stabilityEnergyReqPercent = computed(() =>
Math.max(
0,
Math.min(
100,
getPracticeNumber(
practiceInfo.value.energyReqPercent,
trainingParams.value.energyReqPercent
)
)
)
);
const stabilityScoreSlot = computed(() =>
Math.max(
0,
getPracticeNumber(
practiceInfo.value.scoreSlot,
trainingParams.value.scoreSlot
)
)
);
const stabilityCurrentEnergy = computed(() => {
const currentEnergy = Math.max(
0,
getPracticeNumber(practiceInfo.value.currentEnergy)
);
return stabilityScoreSlot.value > 0
? Math.min(stabilityScoreSlot.value, currentEnergy)
: currentEnergy;
});
const stabilityEnergyPercent = computed(() => {
if (stabilityScoreSlot.value <= 0) return 0;
return Math.min(
100,
(stabilityCurrentEnergy.value / stabilityScoreSlot.value) * 100
);
});
const initializeRhythmFirstRoundCountdown = () => {
rhythmHasActiveServerAnchor.value = false;
rhythmFallbackWindowStart.value = 0;
@@ -444,6 +497,27 @@ const trainingCopy = computed(() => {
};
}
if (trainingType.value === "stability") {
const hitReq = getPracticeNumber(
practiceInfo.value.hitReq,
trainingParams.value.hitReq
);
return {
inline: true,
details: [
{ text: "射箭命中" },
{ text: `${hitReq}环及以上`, highlight: true },
{ text: `每箭可获得${stabilityEnergyPerHit.value}点能量,` },
{ text: "计时结束时能量需达到" },
{
text: `${stabilityEnergyReqPercent.value}%`,
highlight: true,
},
],
};
}
return null;
});
@@ -481,6 +555,18 @@ const practiceInfoFields = [
"shootTime",
"shootWindowStart",
"inShootWindow",
"scoreSlot",
"currentEnergy",
"energyCostPerSec",
"energyPerHit",
"energyReqPercent",
"deltaCurrentEnergy",
"maxEnergyPercent",
"qualifiedArrows",
"qualifiedRate",
"deltaMaxEnergyPercent",
"deltaQualifiedArrows",
"deltaQualifiedRate",
"timeLimit",
"completed",
"totalArrows",
@@ -519,6 +605,18 @@ const practiceResultFields = [
"maxCombo",
"totalHits",
"currentRings",
"scoreSlot",
"currentEnergy",
"energyCostPerSec",
"energyPerHit",
"energyReqPercent",
"deltaCurrentEnergy",
"maxEnergyPercent",
"qualifiedArrows",
"qualifiedRate",
"deltaMaxEnergyPercent",
"deltaQualifiedArrows",
"deltaQualifiedRate",
"deltaTotalHits",
"deltaDuration",
"deltaMaxCombo",
@@ -600,6 +698,9 @@ const cacheRhythmTargetArrows = (message = {}) => {
const syncPracticeInfo = (message = {}) => {
cacheRhythmTrainingConfig(message);
cacheRhythmTargetArrows(message);
const messageTrainingType = String(
message.trainingType ?? message.training_type ?? trainingType.value
).trim();
const nextInfo = practiceInfoFields.reduce((result, field) => {
if (Object.prototype.hasOwnProperty.call(message, field)) {
result[field] = message[field];
@@ -607,6 +708,24 @@ const syncPracticeInfo = (message = {}) => {
return result;
}, {});
const isStabilityEnergySnapshot =
messageTrainingType === "stability" &&
(Object.prototype.hasOwnProperty.call(message, "currentEnergy") ||
message.type === undefined ||
[
MESSAGETYPESV2.BattleStart,
MESSAGETYPESV2.ShootResult,
MESSAGETYPESV2.BattleEnd,
].includes(message.type));
if (isStabilityEnergySnapshot) {
// BattleStart/ShootResult/PracticeEnd/同步消息都是稳定训练完整快照。
// current_energy 为 0 时 protobuf 不编码,不能沿用上一份非零能量。
if (!Object.prototype.hasOwnProperty.call(nextInfo, "currentEnergy")) {
nextInfo.currentEnergy = 0;
}
}
const isPrecisionSnapshot =
(message.type === MESSAGETYPESV2.BattleStart ||
message.type === MESSAGETYPESV2.ShootResult) &&
@@ -1216,7 +1335,10 @@ onLoad((options = {}) => {
toRouteNumber(trainingContext.difficultyLevel)
),
recordId: options.recordId || "",
hitReq: toRouteNumber(options.hitReq),
hitReq: toRouteNumber(
options.hitReq,
toRouteNumber(trainingContext.hitReq)
),
totalReq: toRouteNumber(options.totalReq),
blocks: toRouteNumber(options.blocks),
mode: toRouteNumber(options.mode),
@@ -1228,6 +1350,22 @@ onLoad((options = {}) => {
options.shootTime,
toRouteNumber(trainingContext.shootTime)
),
energyPerHit: toRouteNumber(
options.energyPerHit,
toRouteNumber(trainingContext.energyPerHit)
),
energyCostPerSec: toRouteNumber(
options.energyCostPerSec,
toRouteNumber(trainingContext.energyCostPerSec)
),
energyReqPercent: toRouteNumber(
options.energyReqPercent,
toRouteNumber(trainingContext.energyReqPercent)
),
scoreSlot: toRouteNumber(
options.scoreSlot,
toRouteNumber(trainingContext.scoreSlot)
),
};
practiseId.value = trainingContext.practiceId || "";
serverAddr.value = trainingContext.serverAddr || "";
@@ -1238,6 +1376,11 @@ onLoad((options = {}) => {
warmupKeys.push("Bingo命中目标", "未命中");
} else if (trainingParams.value.type === "rhythm") {
warmupKeys.push(RHYTHM_SHOOT_WINDOW_AUDIO_KEY, "Perfect", "miss");
} else if (trainingParams.value.type === "stability") {
warmupKeys.push(
STABILITY_ENERGY_50_AUDIO_KEY,
STABILITY_ENERGY_70_AUDIO_KEY
);
}
void audioManager.warmKeys(warmupKeys.filter(Boolean));
});
@@ -1406,6 +1549,30 @@ async function onReceiveMessage(msg) {
if (hasNewShot) {
shotEffectToken.value += 1;
}
} else if (trainingType.value === "stability") {
const hasStabilityShot =
msg.stabilityHasNewShot === true || hasNewShot;
const latestDetail =
Array.isArray(msg.details) && msg.details.length > 0
? msg.details[msg.details.length - 1]
: null;
const arrow = msg.shootData || latestDetail;
const audioKeys = hasStabilityShot
? getStabilityShotAudioKeys(arrow)
: [];
const milestoneAudioKey = String(
msg.stabilityMilestoneAudioKey || ""
).trim();
// WebSocket 管理器负责阈值判定并把同一语音 key 用于 ACK;这里按
// 环数/最高跨越阈值的顺序一次入队。
if (milestoneAudioKey) audioKeys.push(milestoneAudioKey);
if (audioKeys.length > 0) {
audioManager.play(audioKeys, false);
}
if (hasStabilityShot) {
shotEffectToken.value += 1;
}
} else if (hasNewShot) {
shotEffectToken.value += 1;
}
@@ -1579,9 +1746,19 @@ onBeforeUnmount(() => {
:showBottom="isDistanceStage"
:scroll="!isShootingStage"
:onBack="exitPractice"
:title="isShootingStage && isRhythmTraining ? '节奏训练' : ''"
:title="
isShootingStage
? isRhythmTraining
? '节奏训练'
: isStabilityTraining
? '稳定训练'
: ''
: ''
"
:titleStyle="
isShootingStage && isRhythmTraining ? rhythmHeaderTitleStyle : {}
isShootingStage && (isRhythmTraining || isStabilityTraining)
? rhythmHeaderTitleStyle
: {}
"
>
<view class="practise-content">
@@ -1604,10 +1781,14 @@ onBeforeUnmount(() => {
:inShootWindow="rhythmInShootWindow"
:serverTimestamp="rhythmCountdownTimestamp"
:hitReq="rhythmHitReq"
:energyPercent="stabilityEnergyPercent"
:energyReqPercent="stabilityEnergyReqPercent"
:isVip="isVip"
:isSvip="isSvip"
:externalShootResultAudio="
trainingType === 'precision' || trainingType === 'rhythm'
trainingType === 'precision' ||
trainingType === 'rhythm' ||
trainingType === 'stability'
"
:onStop="onTimeLimitReached"
/>
+1 -1
View File
File diff suppressed because one or more lines are too long
+8 -2
View File
@@ -7,8 +7,8 @@ const { Reader, Writer } = protobuf;
// 所以这里使用 minimal Reader/Writer 做静态字段解码和客户端消息编码。
// <match-schema-generated>
// 此区块由 scripts/generate-match-schema.mjs 自动生成,请勿手动修改。
// 来源:src/utils/match.min.jssha256: fbf97fd3d08ae314
// 协议命名空间:rpc;消息数:12;字段数:155
// 来源:src/utils/match.min.jssha256: 0fe50dfa2644f750
// 协议命名空间:rpc;消息数:12;字段数:161
export const ServerMessageType = {
SERVER_MSG_UNKNOWN: 0,
@@ -198,6 +198,12 @@ const SCHEMAS = {
53: { name: "shoot_time", kind: "int32" },
54: { name: "ten_ring_count", kind: "int32" },
55: { name: "delta_ten_ring_count", kind: "int32" },
56: { name: "max_energy_percent", kind: "int32" },
57: { name: "qualified_arrows", kind: "int32" },
58: { name: "qualified_rate", kind: "int32" },
59: { name: "delta_max_energy_percent", kind: "int32" },
60: { name: "delta_qualified_arrows", kind: "int32" },
61: { name: "delta_qualified_rate", kind: "int32" },
},
MatchInfo: {
1: { name: "match_id", kind: "string" },