From 96b64e41878a2dd0e7cdc0a86545fd2db5ffe8f2 Mon Sep 17 00:00:00 2001 From: zhangyibo95 <690096405@qq.com> Date: Wed, 26 Aug 2026 15:23:02 +0800 Subject: [PATCH] =?UTF-8?q?update=EF=BC=9A=E6=96=B0=E5=A2=9E=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=E8=AE=AD=E7=BB=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/apis.js | 4 +- src/audioManager.js | 22 ++ src/components/TrainingScorePanel.vue | 5 +- src/matchWebsocket.js | 194 ++++++++++++++++++ src/pages/mine-bow-data.vue | 1 + src/pages/my-growth.vue | 1 + src/pages/training/components/ScorePanel.vue | 4 +- src/pages/training/components/ScoreResult.vue | 34 ++- .../training/components/ShootProgress.vue | 114 +++++++++- .../TrainingDifficultyPreviewCard.vue | 28 ++- src/pages/training/difficulty.vue | 58 +++++- src/pages/training/index.vue | 4 +- src/pages/training/practise-one.vue | 189 ++++++++++++++++- src/utils/match.min.js | 2 +- src/utils/matchProtocol.js | 10 +- 15 files changed, 645 insertions(+), 25 deletions(-) diff --git a/src/apis.js b/src/apis.js index 7eb6abc..6bf5715 100644 --- a/src/apis.js +++ b/src/apis.js @@ -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; diff --git a/src/audioManager.js b/src/audioManager.js index baeb623..fec9d59 100644 --- a/src/audioManager.js +++ b/src/audioManager.js @@ -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", 请于计时前完成累计环数和箭量: diff --git a/src/components/TrainingScorePanel.vue b/src/components/TrainingScorePanel.vue index b034ecc..90ac90f 100644 --- a/src/components/TrainingScorePanel.vue +++ b/src/components/TrainingScorePanel.vue @@ -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; } diff --git a/src/matchWebsocket.js b/src/matchWebsocket.js index 37d105b..09bb6fe 100644 --- a/src/matchWebsocket.js +++ b/src/matchWebsocket.js @@ -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, diff --git a/src/pages/mine-bow-data.vue b/src/pages/mine-bow-data.vue index 1217819..92d6370 100644 --- a/src/pages/mine-bow-data.vue +++ b/src/pages/mine-bow-data.vue @@ -18,6 +18,7 @@ const trainingTypeNameMap = Object.freeze({ endurance: "耐力训练", precision: "精准训练", rhythm: "节奏训练", + stability: "稳定训练", }); const trainingType = computed(() => diff --git a/src/pages/my-growth.vue b/src/pages/my-growth.vue index 35f4e3d..7cd4b63 100644 --- a/src/pages/my-growth.vue +++ b/src/pages/my-growth.vue @@ -20,6 +20,7 @@ const trainingTypeNameMap = Object.freeze({ endurance: "耐力训练", precision: "精准训练", rhythm: "节奏训练", + stability: "稳定训练", }); const getTrainingTypeName = (trainingType) => { diff --git a/src/pages/training/components/ScorePanel.vue b/src/pages/training/components/ScorePanel.vue index 9d81ab7..f995bd9 100644 --- a/src/pages/training/components/ScorePanel.vue +++ b/src/pages/training/components/ScorePanel.vue @@ -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; diff --git a/src/pages/training/components/ScoreResult.vue b/src/pages/training/components/ScoreResult.vue index 964b62c..4eeecc2 100644 --- a/src/pages/training/components/ScoreResult.vue +++ b/src/pages/training/components/ScoreResult.vue @@ -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( -- - + 消耗卡路里 diff --git a/src/pages/training/components/ShootProgress.vue b/src/pages/training/components/ShootProgress.vue index ae01fa8..e2ce352 100644 --- a/src/pages/training/components/ShootProgress.vue +++ b/src/pages/training/components/ShootProgress.vue @@ -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(() => {