diff --git a/src/audioManager.js b/src/audioManager.js index 595e25f..ec10ec3 100644 --- a/src/audioManager.js +++ b/src/audioManager.js @@ -18,6 +18,10 @@ const TRAINING_START_AUDIO_KEY_MAP = Object.freeze({ export const getTrainingStartAudioKey = (trainingType, fallback = "") => TRAINING_START_AUDIO_KEY_MAP[trainingType] || fallback; +// 与服务端通关判定保持一致,proto3 省略的 false 同样按失败处理。 +export const getTrainingEndAudioKey = (completed) => + completed === true ? "通关成功" : "通关失败"; + export const getPrecisionShotAudioKeys = (shootData, directionText = "") => { if (!shootData || typeof shootData !== "object") return []; const ring = Number(shootData.ring); @@ -201,6 +205,10 @@ export const audioFils = { "https://static.shelingxingqiu.com/shootmini/static/audio0820/%E6%9C%AA%E5%91%BD%E4%B8%AD.MP3", 练习结束: "https://static.shelingxingqiu.com/shootmini/static/audio/%E7%BB%83%E4%B9%A0%E7%BB%93%E6%9D%9F.mp3", + 通关成功: + "https://static.shelingxingqiu.com/shootaudio/%E9%80%9A%E5%85%B3%E6%88%90%E5%8A%9F.mp3", + 通关失败: + "https://static.shelingxingqiu.com/shootaudio/%E9%80%9A%E5%85%B3%E5%A4%B1%E8%B4%A5.mp3", 射箭声音: "https://static.shelingxingqiu.com/shootaudio/v4/v4/%E7%AE%AD%E9%A3%9E%E8%A1%8C.mp3", 命中: diff --git a/src/matchWebsocket.js b/src/matchWebsocket.js index 09bb6fe..c0f6020 100644 --- a/src/matchWebsocket.js +++ b/src/matchWebsocket.js @@ -18,6 +18,7 @@ import { getPrecisionShotAudioKeys, getRhythmShotAudioKeys, getStabilityShotAudioKeys, + getTrainingEndAudioKey, getTrainingStartAudioKey, STABILITY_ENERGY_50_AUDIO_KEY, STABILITY_ENERGY_70_AUDIO_KEY, @@ -603,6 +604,10 @@ function getAckAudioKeys(message, businessMessage) { if (businessMessage?.type === MESSAGETYPESV2.HalfRest) return ["中场休息"]; return ["比赛结束"]; case ServerMessageType.SERVER_MSG_PRACTICE_END: + // 个人训练的 ACK 等待实际播放的通关语音,其他练习保留原有配置。 + if (currentContext?.practiceEndAudioKey === "练习结束") { + return [getTrainingEndAudioKey(businessMessage?.completed)]; + } return [currentContext?.practiceEndAudioKey || "比赛结束"]; case ServerMessageType.SERVER_MSG_CHECK: return getTestDistanceAudioKeys(businessMessage?.shootData); diff --git a/src/pages/training/components/ShootProgress.vue b/src/pages/training/components/ShootProgress.vue index 9b05675..410aa7a 100644 --- a/src/pages/training/components/ShootProgress.vue +++ b/src/pages/training/components/ShootProgress.vue @@ -8,6 +8,7 @@ import { nextTick, } from "vue"; import audioManager, { + getTrainingEndAudioKey, getTrainingStartAudioKey, RHYTHM_SHOOT_WINDOW_AUDIO_KEY, } from "@/audioManager"; @@ -111,6 +112,8 @@ const props = defineProps({ }, }); +const emit = defineEmits(["rhythm-round-complete"]); + const trainingTitleIconMap = Object.freeze({ base: "https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/text-icon-jcxl.png", @@ -143,6 +146,7 @@ let rhythmCountdownTimer = null; let rhythmTransitionTimer = null; let rhythmServerClockOffsetMs = 0; let rhythmSyncGeneration = 0; +let rhythmRoundCompletionTimer = null; const isRhythmTraining = computed(() => props.trainingType === "rhythm"); const isStabilityTraining = computed( @@ -306,6 +310,30 @@ const getRhythmRoundRemainingMs = () => { ); }; +const clearRhythmRoundCompletionTimer = () => { + if (rhythmRoundCompletionTimer !== null) { + clearTimeout(rhythmRoundCompletionTimer); + rhythmRoundCompletionTimer = null; + } +}; + +// 与当前显示的倒计时一起计时;消息校准不重建此任务,避免同一轮重复计数。 +const scheduleRhythmRoundCompletion = (remainingMs) => { + if ( + rhythmRoundCompletionTimer !== null || + !props.start || + !isRhythmTraining.value || + remainingMs <= 0 + ) return; + + rhythmRoundCompletionTimer = setTimeout(() => { + rhythmRoundCompletionTimer = null; + if (!props.start || !isRhythmTraining.value) return; + emit("rhythm-round-complete"); + scheduleRhythmRoundCompletion(rhythmRoundDurationMs.value); + }, remainingMs); +}; + const updateRhythmShootWindow = (isInWindow) => { const nextIsInWindow = Boolean(isInWindow); const enteredShootWindow = @@ -364,7 +392,8 @@ const syncRhythmCountdown = async () => { const generation = ++rhythmSyncGeneration; clearRhythmTimers(); - if (!isRhythmTraining.value) { + if (!isRhythmTraining.value || !props.start) { + clearRhythmRoundCompletionTimer(); rhythmTransitionStyle.value = "none"; rhythmRemainingMs.value = 0; rhythmRemainingSeconds.value = 0; @@ -382,6 +411,7 @@ const syncRhythmCountdown = async () => { !validRhythmShootTime.value || !normalizeTimestamp(props.shootWindowStart) ) { + clearRhythmRoundCompletionTimer(); rhythmTransitionStyle.value = "none"; rhythmRemainingMs.value = 0; rhythmRemainingSeconds.value = 0; @@ -391,6 +421,7 @@ const syncRhythmCountdown = async () => { rhythmTransitionStyle.value = "none"; rhythmRemainingMs.value = getRhythmRoundRemainingMs(); + scheduleRhythmRoundCompletion(rhythmRemainingMs.value); rhythmRemainingSeconds.value = Math.ceil(rhythmRemainingMs.value / 1000); updateRhythmShootWindow( rhythmRemainingMs.value > 0 && @@ -462,6 +493,7 @@ watch( watch( () => [ + props.start, props.trainingType, props.roundTime, props.shootTime, @@ -493,7 +525,7 @@ async function onReceiveMessage(msg) { audioManager.play("比赛开始"); } } else if (msg.type === MESSAGETYPESV2.BattleEnd) { - audioManager.play("练习结束", false); + audioManager.play(getTrainingEndAudioKey(msg.completed), false); } else if (msg.type === MESSAGETYPESV2.ShootResult) { // 精准和节奏训练由页面统一处理专属结果语音,其他训练保持原播放链路。 if (props.externalShootResultAudio) return; @@ -550,6 +582,7 @@ onBeforeUnmount(() => { clearTimer(); rhythmSyncGeneration += 1; clearRhythmTimers(); + clearRhythmRoundCompletionTimer(); }); diff --git a/src/pages/training/difficulty.vue b/src/pages/training/difficulty.vue index 160fb31..72ca27f 100644 --- a/src/pages/training/difficulty.vue +++ b/src/pages/training/difficulty.vue @@ -111,57 +111,50 @@ const createDifficultySummary = (item = {}) => { const hitReq = toNumber(item.hit_req); const totalReq = toNumber(item.total_req); const rhythmRequiredArrows = toNumber(item.total_req, arrows) || arrows; - const timeLine = { - parts: timeLimit > 0 - ? [{ text: "限时" }, { text: `${timeLimit}秒`, highlight: true }] - : [{ text: "不限时" }], - }; + const timeParts = timeLimit > 0 + ? [{ text: `${timeLimit}`, highlight: true }, { text: "秒内," }] + : [{ text: "不限时," }]; const summaryMap = { base: [ { parts: [ - { text: "需有" }, { text: `${arrows}箭`, highlight: true }, - { text: "命中" }, { text: `≥${hitReq}环`, highlight: true }, + ...timeParts, + { text: "命中" }, { text: `${arrows}`, highlight: true }, + { text: "支" }, { text: `≥${hitReq}`, highlight: true }, + { text: "环的箭" }, ] }, - timeLine, ], endurance: [ { parts: [ - { text: "需射箭数" }, { text: `≥${arrows}箭`, highlight: true }, - { text: ",累计环数" }, { text: `≥${totalReq}环`, highlight: true }, + ...timeParts, + { text: "共射出" }, { text: `≥${arrows}`, highlight: true }, + { text: "支箭,且总环数" }, { text: `≥${totalReq}`, highlight: true }, + { text: "环" }, ] }, - timeLine, ], precision: [ { parts: [ - { text: "需有" }, { text: `${arrows}箭`, highlight: true }, - { text: "命中高亮区域" }, + ...timeParts, + { text: "命中" }, { text: `${arrows}`, highlight: true }, + { text: "箭高亮区域" }, ] }, - timeLine, ], rhythm: [ { parts: [ - { text: "跟随节拍撒放,共" }, { text: `${arrows}箭`, highlight: true }, - { text: "需" }, { text: `${rhythmRequiredArrows}箭`, highlight: true }, - ] }, - { parts: [ - { text: "命中" }, { text: `≥${hitReq}环`, highlight: true }, - { text: "且踏中节拍区间" }, + { text: `${arrows}`, highlight: true }, + { text: "个节奏内,随节奏射箭,命中" }, + { text: `${rhythmRequiredArrows}`, highlight: true }, + { text: "箭成绩" }, { text: `≥${hitReq}`, highlight: true }, + { text: "环且踩中节拍区间" }, ] }, ], stability: [ { parts: [ - { text: "命中" }, - { text: `≥${hitReq}环`, highlight: true }, - { text: "可为能量槽充能" }, - ], - }, - { - parts: [ - { text: timeLimit > 0 ? "限时 " : "不限时" }, - { text: timeLimit > 0 ? `${timeLimit}` : "", highlight: true }, - { text: timeLimit > 0 ? " 秒,能量打满即过关" : ",能量打满即过关" }, + ...timeParts, + { text: "每射一箭" }, + { text: `≥${hitReq}`, highlight: true }, + { text: "环可为能量槽充能,能量充满即过关" }, ], }, ], diff --git a/src/pages/training/practise-one.vue b/src/pages/training/practise-one.vue index 3dbb376..579ceb3 100644 --- a/src/pages/training/practise-one.vue +++ b/src/pages/training/practise-one.vue @@ -102,8 +102,8 @@ const rhythmServerTimestamp = ref(0); const rhythmFallbackWindowStart = ref(0); const rhythmFallbackTimestamp = ref(0); const rhythmHasActiveServerAnchor = ref(false); -// 节奏训练目标箭数以服务端 total_arrows 为准;首帧缺失时用 arrows_left 初始化并保持稳定。 -const rhythmTargetArrows = ref(0); +// 仅由前端每轮倒计时完成事件累加,不根据服务端箭数推算。 +const rhythmCompletedRounds = ref(0); // 服务端状态立即落到 practiceInfo,精准训练的目标区域单独延迟展示。 const visiblePrecisionTarget = ref({ randomBlock: 0, @@ -440,10 +440,11 @@ const trainingCopy = computed(() => { return { inline: true, details: [ - { text: "需有 " }, + { text: "命中 " }, { text: `(${completedArrows}/${targetArrows})`, highlight: true }, - { text: " 箭命中" }, - { text: `≥${hitReq}环`, highlight: true }, + { text: " 支" }, + { text: `≥${hitReq}`, highlight: true }, + { text: "环的箭" }, ], }; } @@ -463,10 +464,11 @@ const trainingCopy = computed(() => { return { inline: true, details: [ - { text: "需射箭数" }, - { text: `≥ (${currentArrows}/${targetArrows}) 支`, highlight: true }, - { text: ",累计环数\n" }, - { text: `≥ (${currentRings}/${targetRings}) 环`, highlight: true }, + { text: "射出≥ " }, + { text: `(${currentArrows}/${targetArrows})`, highlight: true }, + { text: " 支箭,总环数达到≥ " }, + { text: `(${currentRings}/${targetRings})`, highlight: true }, + { text: " 环" }, ], }; } @@ -485,38 +487,33 @@ const trainingCopy = computed(() => { return { inline: true, details: [ - { text: "需有 " }, + { text: "命中高亮区域 " }, { text: `(${completedArrows}/${targetArrows})`, highlight: true }, - { text: " 箭命中高亮区域" }, + { text: " 箭" }, ], }; } if (trainingType.value === "rhythm") { - const totalArrows = getPositiveInteger(rhythmTargetArrows.value) || total.value; - // 节奏训练进度由总箭数减剩余箭数计算,qualifiedArrows 仅用于稳定训练。 + // 总节奏数沿用关卡配置,与选择页保持一致,不使用消息中的箭数覆盖。 + const totalArrows = getPositiveInteger(total.value); + // 节奏数由前端倒计时累计,达标箭数与下方金色箭记录使用同一判定。 const targetArrows = getPositiveInteger(practiceInfo.value.targetArrows) || getPositiveInteger(trainingParams.value.totalReq) || totalArrows; - const completedArrows = Math.min( - totalArrows, - Math.max( - 0, - totalArrows - getPracticeNumber(practiceInfo.value.arrowsLeft, totalArrows) - ) - ); + const completedArrows = rhythmCompletedRounds.value; const hitReq = rhythmHitReq.value; + const qualifiedArrows = scores.value.filter((arrow) => arrow?.ok === true).length; return { inline: true, details: [ - { text: "跟随节拍撒放,共" }, - { text: `${totalArrows}箭`, highlight: true }, - { text: "需 " }, - { text: `(${completedArrows}/${targetArrows}) 箭`, highlight: true }, - { text: "\n命中" }, - { text: `≥${hitReq}环`, highlight: true }, - { text: "且踏中节拍区间" }, + { text: `(${completedArrows}/${totalArrows})`, highlight: true }, + { text: " 个节奏内,跟随节奏射箭,命中 " }, + { text: `(${qualifiedArrows}/${targetArrows})`, highlight: true }, + { text: " 箭" }, + { text: `≥${hitReq}`, highlight: true }, + { text: "环且踩中节拍区间" }, ], }; } @@ -529,9 +526,9 @@ const trainingCopy = computed(() => { return { inline: true, details: [ - { text: "命中" }, - { text: `≥${hitReq}环`, highlight: true }, - { text: "可为能量槽充能,能量打满即过关" }, + { text: "每射一箭" }, + { text: `≥${hitReq}`, highlight: true }, + { text: "环可为能量槽充能,充满即过关" }, ], }; } @@ -696,31 +693,14 @@ const cacheRhythmTrainingConfig = (message = {}) => { } }; -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 onRhythmRoundComplete = () => { + if (!isShootingStage.value || !start.value || !isRhythmTraining.value || practiceEnded.value) return; + const totalRounds = getPositiveInteger(total.value); + rhythmCompletedRounds.value = Math.min(totalRounds, rhythmCompletedRounds.value + 1); }; const syncPracticeInfo = (message = {}) => { cacheRhythmTrainingConfig(message); - cacheRhythmTargetArrows(message); const messageTrainingType = String( message.trainingType ?? message.training_type ?? trainingType.value ).trim(); @@ -741,11 +721,9 @@ const syncPracticeInfo = (message = {}) => { ].includes(message.type)); if (isRhythmSnapshot) { - // 完整快照中 proto3 会省略 0;最后一箭及重连时不能保留旧剩余数。 - for (const field of ["arrowsLeft", "qualifiedArrows"]) { - if (!Object.prototype.hasOwnProperty.call(nextInfo, field)) { - nextInfo[field] = 0; - } + // 达标箭数的 0 按 protobuf 默认值处理;剩余数缺失不能用于推算节奏进度。 + if (!Object.prototype.hasOwnProperty.call(nextInfo, "qualifiedArrows")) { + nextInfo.qualifiedArrows = 0; } } @@ -1427,7 +1405,7 @@ onLoad((options = {}) => { serverAddr.value = trainingContext.serverAddr || ""; const startAudioKey = getTrainingStartAudioKey(trainingParams.value.type); - const warmupKeys = [startAudioKey, "胜利", "失败"]; + const warmupKeys = [startAudioKey, "通关成功", "通关失败", "胜利", "失败"]; if (trainingParams.value.type === "precision") { warmupKeys.push("Bingo命中目标", "未命中"); } else if (trainingParams.value.type === "rhythm") { @@ -1457,6 +1435,7 @@ const onReady = async () => { } practiceStarting.value = true; + rhythmCompletedRounds.value = 0; pageStage.value = pageStages.LOADING; clearHighlightTestTimer(); useHighlightTest.value = false; @@ -1698,7 +1677,7 @@ async function onRetry() { rhythmFallbackWindowStart.value = 0; rhythmFallbackTimestamp.value = 0; rhythmHasActiveServerAnchor.value = false; - rhythmTargetArrows.value = 0; + rhythmCompletedRounds.value = 0; invalidateShotPresentations({ resetVisible: true }); start.value = false; scores.value = []; @@ -1721,7 +1700,7 @@ function onAudioEnded(key) { Array.from(audioWaiters).forEach((waiter) => { if (waiter.expectedKey === key) waiter.done(); }); - if (["比赛结束", "练习结束"].includes(String(key || ""))) { + if (["比赛结束", "练习结束", "通关成功", "通关失败"].includes(String(key || ""))) { void onOver(); } } @@ -1869,6 +1848,7 @@ onBeforeUnmount(() => { trainingType === 'stability' " :onStop="onTimeLimitReached" + @rhythm-round-complete="onRhythmRoundComplete" />