update:优化节奏训练

This commit is contained in:
2026-08-24 16:35:56 +08:00
parent 59fa317c5a
commit 35e0ee3ca0
9 changed files with 679 additions and 41 deletions
+231 -2
View File
@@ -12,7 +12,9 @@ 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 {
@@ -26,12 +28,13 @@ import {
import {
connectMatchWebSocket,
closeMatchWebSocket,
requestPracticeInfoSync,
setMatchAppHideResumable,
MATCH_WS_PRACTICE_SYNC_EVENT,
MATCH_WS_STATE_EVENT,
} from "@/matchWebsocket";
import { sharePractiseData } from "@/canvas";
import { wxShare, debounce, getDirectionText } from "@/util";
import { wxShare, debounce, getDirectionText, capsuleHeight } from "@/util";
import { MESSAGETYPESV2, roundsName } from "@/constants";
import useStore from "@/store";
@@ -50,6 +53,25 @@ const pageStages = Object.freeze({
});
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);
// 可见区域每次正式提交都递增,同一区域连续刷新也能触发动效。
@@ -66,6 +88,10 @@ 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);
// 服务端状态立即落到 practiceInfo,精准训练的目标区域单独延迟展示。
const visiblePrecisionTarget = ref({
randomBlock: 0,
@@ -236,6 +262,55 @@ const loadNextDifficultyState = (result = {}) => {
// 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) {
@@ -340,6 +415,33 @@ const trainingCopy = computed(() => {
};
}
if (trainingType.value === "rhythm") {
const targetArrows = getPositiveInteger(total.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;
});
@@ -373,6 +475,10 @@ const practiceInfoFields = [
"blocks",
"randomBlock",
"randomRingArea",
"roundTime",
"shootTime",
"shootWindowStart",
"inShootWindow",
"timeLimit",
"completed",
"totalArrows",
@@ -425,7 +531,46 @@ const practiceResultFields = [
"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 syncPracticeInfo = (message = {}) => {
cacheRhythmTrainingConfig(message);
const nextInfo = practiceInfoFields.reduce((result, field) => {
if (Object.prototype.hasOwnProperty.call(message, field)) {
result[field] = message[field];
@@ -477,6 +622,23 @@ const buildShootResultAudioKeys = (message = {}) => {
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();
@@ -702,6 +864,16 @@ const onPracticeInfoSync = (payload = {}) => {
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;
@@ -1019,6 +1191,14 @@ onLoad((options = {}) => {
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 || "";
@@ -1027,6 +1207,8 @@ onLoad((options = {}) => {
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));
});
@@ -1057,9 +1239,12 @@ const onReady = async () => {
practiseResult.value = {};
scores.value = [];
shotEffectToken.value = 0;
initializeRhythmFirstRoundCountdown();
start.value = true;
pageStage.value = pageStages.SHOOTING;
setPracticeAppHideResumable(true);
// 先由本地锚点保证首轮立即显示,再用最新服务端快照无感校准。
requestPracticeInfoSync();
// 开始接口成功即进入正式训练,直接播放对应提示,避免依赖 BattleStart 消息。
audioManager.play(
getTrainingStartAudioKey(trainingType.value, "练习开始")
@@ -1125,7 +1310,27 @@ const onOver = async () => {
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();
@@ -1164,6 +1369,14 @@ async function onReceiveMessage(msg) {
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;
}
@@ -1210,6 +1423,10 @@ async function onRetry() {
practiseResult.value = {};
practiceEndSnapshot.value = {};
practiceInfo.value = {};
rhythmServerTimestamp.value = 0;
rhythmFallbackWindowStart.value = 0;
rhythmFallbackTimestamp.value = 0;
rhythmHasActiveServerAnchor.value = false;
invalidateShotPresentations({ resetVisible: true });
start.value = false;
scores.value = [];
@@ -1332,6 +1549,10 @@ onBeforeUnmount(() => {
:showBottom="isDistanceStage"
:scroll="!isShootingStage"
:onBack="exitPractice"
:title="isShootingStage && isRhythmTraining ? '节奏训练' : ''"
:titleStyle="
isShootingStage && isRhythmTraining ? rhythmHeaderTitleStyle : {}
"
>
<view class="practise-content">
<TestDistance
@@ -1347,9 +1568,17 @@ onBeforeUnmount(() => {
: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'"
:externalShootResultAudio="
trainingType === 'precision' || trainingType === 'rhythm'
"
:onStop="onTimeLimitReached"
/>
<view class="user-row">