update:优化节奏训练
This commit is contained in:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user