update:优化节奏训练
This commit is contained in:
@@ -1,10 +1,12 @@
|
|||||||
export const AUDIO_INTERRUPTION_BEGIN_EVENT = "audio-interruption-begin";
|
export const AUDIO_INTERRUPTION_BEGIN_EVENT = "audio-interruption-begin";
|
||||||
export const AUDIO_INTERRUPTION_END_EVENT = "audio-interruption-end";
|
export const AUDIO_INTERRUPTION_END_EVENT = "audio-interruption-end";
|
||||||
|
export const RHYTHM_SHOOT_WINDOW_AUDIO_KEY = "请射箭";
|
||||||
|
|
||||||
const TRAINING_START_AUDIO_KEY_MAP = Object.freeze({
|
const TRAINING_START_AUDIO_KEY_MAP = Object.freeze({
|
||||||
base: "请于计时前完成指定环数对应箭量",
|
base: "请于计时前完成指定环数对应箭量",
|
||||||
endurance: "请于计时前完成累计环数和箭量",
|
endurance: "请于计时前完成累计环数和箭量",
|
||||||
precision: "请射箭命中高亮区域",
|
precision: "请射箭命中高亮区域",
|
||||||
|
rhythm: "请在读条推进到标记区间内射箭",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getTrainingStartAudioKey = (trainingType, fallback = "") =>
|
export const getTrainingStartAudioKey = (trainingType, fallback = "") =>
|
||||||
@@ -28,6 +30,17 @@ export const getPrecisionShotAudioKeys = (shootData, directionText = "") => {
|
|||||||
return [directionAudioKey, hitAudioKey].filter(Boolean);
|
return [directionAudioKey, hitAudioKey].filter(Boolean);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getRhythmShotAudioKeys = (shootData) => {
|
||||||
|
if (!shootData || typeof shootData !== "object") return [];
|
||||||
|
const ring = Number(shootData.ring);
|
||||||
|
const ringAudioKey =
|
||||||
|
Number.isFinite(ring) && ring > 0
|
||||||
|
? `${shootData.ringX ? "X" : ring}环`
|
||||||
|
: "未上靶";
|
||||||
|
// 节奏训练是否达标完全由服务端 ok 字段决定,前端不根据环数复算。
|
||||||
|
return [ringAudioKey, shootData.ok === true ? "Perfect" : "miss"];
|
||||||
|
};
|
||||||
|
|
||||||
export const audioFils = {
|
export const audioFils = {
|
||||||
tententen: "https://static.shelingxingqiu.com/shootmini/static/audio/tententen.mp3",
|
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",
|
点击按钮: "https://static.shelingxingqiu.com/shootmini/static/audio/%E7%82%B9%E5%87%BB%E6%8C%89%E9%92%AE.mp3",
|
||||||
@@ -152,6 +165,14 @@ export const audioFils = {
|
|||||||
"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%E7%B4%AF%E8%AE%A1%E7%8E%AF%E6%95%B0%E5%92%8C%E7%AE%AD%E9%87%8F.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%E7%B4%AF%E8%AE%A1%E7%8E%AF%E6%95%B0%E5%92%8C%E7%AE%AD%E9%87%8F.MP3",
|
||||||
请射箭命中高亮区域:
|
请射箭命中高亮区域:
|
||||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E8%AF%B7%E5%B0%84%E7%AE%AD%E5%91%BD%E4%B8%AD%E9%AB%98%E4%BA%AE%E5%8C%BA%E5%9F%9F.MP3",
|
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E8%AF%B7%E5%B0%84%E7%AE%AD%E5%91%BD%E4%B8%AD%E9%AB%98%E4%BA%AE%E5%8C%BA%E5%9F%9F.MP3",
|
||||||
|
请在读条推进到标记区间内射箭:
|
||||||
|
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E8%AF%B7%E5%9C%A8%E8%AF%BB%E6%9D%A1%E6%8E%A8%E8%BF%9B%E5%88%B0%E6%A0%87%E8%AE%B0%E5%8C%BA%E9%97%B4%E5%86%85%E5%B0%84%E7%AE%AD.MP3",
|
||||||
|
[RHYTHM_SHOOT_WINDOW_AUDIO_KEY]:
|
||||||
|
"https://static.shelingxingqiu.com/shootmini/static/audio0820/%E8%AF%B7%E5%B0%84%E7%AE%AD.MP3",
|
||||||
|
Perfect:
|
||||||
|
"https://static.shelingxingqiu.com/shootmini/static/audio0820/Perfect.MP3",
|
||||||
|
miss:
|
||||||
|
"https://static.shelingxingqiu.com/shootmini/static/audio0820/miss.MP3",
|
||||||
Bingo命中目标:
|
Bingo命中目标:
|
||||||
"https://static.shelingxingqiu.com/shootmini/static/audio0820/Bingo%E5%91%BD%E4%B8%AD%E7%9B%AE%E6%A0%87.MP3",
|
"https://static.shelingxingqiu.com/shootmini/static/audio0820/Bingo%E5%91%BD%E4%B8%AD%E7%9B%AE%E6%A0%87.MP3",
|
||||||
未命中:
|
未命中:
|
||||||
|
|||||||
+16
-5
@@ -16,6 +16,7 @@ import {
|
|||||||
} from "@/util";
|
} from "@/util";
|
||||||
import {
|
import {
|
||||||
getPrecisionShotAudioKeys,
|
getPrecisionShotAudioKeys,
|
||||||
|
getRhythmShotAudioKeys,
|
||||||
getTrainingStartAudioKey,
|
getTrainingStartAudioKey,
|
||||||
} from "@/audioManager";
|
} from "@/audioManager";
|
||||||
import {
|
import {
|
||||||
@@ -405,10 +406,9 @@ function getAckAudioKeys(message, businessMessage) {
|
|||||||
) {
|
) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
if (
|
const trainingType =
|
||||||
(businessMessage?.trainingType || currentContext?.trainingType) ===
|
businessMessage?.trainingType || currentContext?.trainingType;
|
||||||
"precision"
|
if (trainingType === "precision") {
|
||||||
) {
|
|
||||||
const shootData = businessMessage?.shootData;
|
const shootData = businessMessage?.shootData;
|
||||||
const directionText =
|
const directionText =
|
||||||
shootData?.angle !== null && shootData?.angle !== undefined
|
shootData?.angle !== null && shootData?.angle !== undefined
|
||||||
@@ -416,6 +416,9 @@ function getAckAudioKeys(message, businessMessage) {
|
|||||||
: "";
|
: "";
|
||||||
return getPrecisionShotAudioKeys(shootData, directionText);
|
return getPrecisionShotAudioKeys(shootData, directionText);
|
||||||
}
|
}
|
||||||
|
if (trainingType === "rhythm") {
|
||||||
|
return getRhythmShotAudioKeys(businessMessage?.shootData);
|
||||||
|
}
|
||||||
return getShootResultAudioKeys(businessMessage?.shootData);
|
return getShootResultAudioKeys(businessMessage?.shootData);
|
||||||
case ServerMessageType.SERVER_MSG_MATCH_END:
|
case ServerMessageType.SERVER_MSG_MATCH_END:
|
||||||
return ["比赛结束"];
|
return ["比赛结束"];
|
||||||
@@ -680,7 +683,9 @@ function sendHeartbeatAck() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendPracticeInfoSync() {
|
function sendPracticeInfoSync() {
|
||||||
if (!socket || !currentContext?.matchId || !currentContext?.userId) return;
|
if (!socket || !currentContext?.matchId || !currentContext?.userId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const clientMessage = {
|
const clientMessage = {
|
||||||
type: ClientMessageType.CLIENT_MSG_SYNC_PRACTICE_INFO,
|
type: ClientMessageType.CLIENT_MSG_SYNC_PRACTICE_INFO,
|
||||||
@@ -695,6 +700,12 @@ function sendPracticeInfoSync() {
|
|||||||
"CLIENT_MSG_SYNC_PRACTICE_INFO",
|
"CLIENT_MSG_SYNC_PRACTICE_INFO",
|
||||||
clientMessage
|
clientMessage
|
||||||
);
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 训练开始后可在不重连 WebSocket 的情况下主动刷新完整练习快照。
|
||||||
|
export function requestPracticeInfoSync() {
|
||||||
|
return sendPracticeInfoSync();
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendAck({ matchId, sequence }) {
|
function sendAck({ matchId, sequence }) {
|
||||||
|
|||||||
@@ -54,6 +54,18 @@ const showComment = ref(false);
|
|||||||
const showBowData = ref(false);
|
const showBowData = ref(false);
|
||||||
const showUpgrade = ref(false);
|
const showUpgrade = ref(false);
|
||||||
|
|
||||||
|
const isCompleted = computed(() => props.result.completed === true);
|
||||||
|
const heroBackground = computed(() =>
|
||||||
|
isCompleted.value
|
||||||
|
? "https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/result-bg.png"
|
||||||
|
: "https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/result-bg-fail.png"
|
||||||
|
);
|
||||||
|
const titleBackground = computed(() =>
|
||||||
|
isCompleted.value
|
||||||
|
? "https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/result-t-bg.png"
|
||||||
|
: "https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/result-t-bg-fail.png"
|
||||||
|
);
|
||||||
|
|
||||||
const closePanel = () => {
|
const closePanel = () => {
|
||||||
showPanel.value = false;
|
showPanel.value = false;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -321,13 +333,13 @@ const calories = computed(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view :class="['result-mask', showPanel ? 'result-mask--show' : 'result-mask--hide']">
|
<view :class="['result-mask', showPanel ? 'result-mask--show' : 'result-mask--hide']">
|
||||||
<image class="hero-glow" src="https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/result-bg.png" mode="widthFix" />
|
<image class="hero-glow" :src="heroBackground" mode="widthFix" />
|
||||||
<view class="result-title">
|
<view class="result-title">
|
||||||
<image class="result-title-bg" src="https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/result-t-bg.png" mode="widthFix" />
|
<image class="result-title-bg" :src="titleBackground" mode="widthFix" />
|
||||||
<view class="result-title-text">Lv{{ resultDifficultyLevel }}</view>
|
<view class="result-title-text">Lv{{ resultDifficultyLevel }}</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="result-panel">
|
<view :class="['result-panel', { 'result-panel--fail': !isCompleted }]">
|
||||||
<view class="line-top"></view>
|
<view class="line-top"></view>
|
||||||
<view class="line-bottom"></view>
|
<view class="line-bottom"></view>
|
||||||
<view class="stats">
|
<view class="stats">
|
||||||
@@ -517,6 +529,10 @@ const calories = computed(
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.result-panel--fail {
|
||||||
|
background: rgba(34, 34, 46, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
.stats {
|
.stats {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: 34rpx;
|
margin-top: 34rpx;
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch, onMounted, onBeforeUnmount, computed } from "vue";
|
import {
|
||||||
import audioManager, { getTrainingStartAudioKey } from "@/audioManager";
|
ref,
|
||||||
|
watch,
|
||||||
|
onMounted,
|
||||||
|
onBeforeUnmount,
|
||||||
|
computed,
|
||||||
|
nextTick,
|
||||||
|
} from "vue";
|
||||||
|
import audioManager, {
|
||||||
|
getTrainingStartAudioKey,
|
||||||
|
RHYTHM_SHOOT_WINDOW_AUDIO_KEY,
|
||||||
|
} from "@/audioManager";
|
||||||
import { MESSAGETYPESV2 } from "@/constants";
|
import { MESSAGETYPESV2 } from "@/constants";
|
||||||
import {
|
import {
|
||||||
getDirectionText,
|
getDirectionText,
|
||||||
@@ -39,6 +49,30 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: "precision",
|
default: "precision",
|
||||||
},
|
},
|
||||||
|
roundTime: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
shootTime: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
shootWindowStart: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
inShootWindow: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
serverTimestamp: {
|
||||||
|
type: [Number, String],
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
hitReq: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
isVip: {
|
isVip: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -93,12 +127,85 @@ const currentRoundEnded = ref(false);
|
|||||||
const halfTime = ref(false);
|
const halfTime = ref(false);
|
||||||
const wait = ref(0);
|
const wait = ref(0);
|
||||||
const transitionStyle = ref("all 1s linear");
|
const transitionStyle = ref("all 1s linear");
|
||||||
|
const rhythmRemainingMs = ref(0);
|
||||||
|
const rhythmRemainingSeconds = ref(0);
|
||||||
|
const rhythmIsShootWindow = ref(false);
|
||||||
|
const rhythmTransitionStyle = ref("none");
|
||||||
|
let rhythmCountdownTimer = null;
|
||||||
|
let rhythmTransitionTimer = null;
|
||||||
|
let rhythmServerClockOffsetMs = 0;
|
||||||
|
let rhythmSyncGeneration = 0;
|
||||||
|
|
||||||
|
const isRhythmTraining = computed(() => props.trainingType === "rhythm");
|
||||||
|
|
||||||
|
const normalizePositiveInteger = (value) => {
|
||||||
|
const numberValue = Number(value);
|
||||||
|
return Number.isFinite(numberValue) && numberValue > 0
|
||||||
|
? Math.round(numberValue)
|
||||||
|
: 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 服务端时间戳可能由 protobuf int64 以字符串形式下发。
|
||||||
|
const normalizeTimestamp = (value) => {
|
||||||
|
const numberValue = Number(value);
|
||||||
|
if (!Number.isFinite(numberValue) || numberValue <= 0) return 0;
|
||||||
|
return numberValue < 1e12 ? numberValue * 1000 : numberValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validRhythmRoundTime = computed(() =>
|
||||||
|
normalizePositiveInteger(props.roundTime)
|
||||||
|
);
|
||||||
|
const validRhythmShootTime = computed(() =>
|
||||||
|
Math.min(
|
||||||
|
normalizePositiveInteger(props.shootTime),
|
||||||
|
validRhythmRoundTime.value
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const validRhythmHitReq = computed(() =>
|
||||||
|
normalizePositiveInteger(props.hitReq)
|
||||||
|
);
|
||||||
|
const rhythmRoundDurationMs = computed(
|
||||||
|
() => validRhythmRoundTime.value * 1000
|
||||||
|
);
|
||||||
|
const rhythmShootDurationMs = computed(
|
||||||
|
() => validRhythmShootTime.value * 1000
|
||||||
|
);
|
||||||
|
|
||||||
const progressPercent = computed(() => {
|
const progressPercent = computed(() => {
|
||||||
if (!props.countdownEnabled || !props.total) return 0;
|
if (!props.countdownEnabled || !props.total) return 0;
|
||||||
return Math.max(0, Math.min(100, (remain.value / props.total) * 100));
|
return Math.max(0, Math.min(100, (remain.value / props.total) * 100));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const rhythmMarkerPercent = computed(() => {
|
||||||
|
if (!validRhythmRoundTime.value) return 0;
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
100,
|
||||||
|
(validRhythmShootTime.value / validRhythmRoundTime.value) * 100
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rhythmProgressPercent = computed(() => {
|
||||||
|
if (!rhythmRoundDurationMs.value) return 0;
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(
|
||||||
|
100,
|
||||||
|
(rhythmRemainingMs.value / rhythmRoundDurationMs.value) * 100
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rhythmTitle = computed(() => {
|
||||||
|
if (!validRhythmShootTime.value) return "节奏训练";
|
||||||
|
if (!validRhythmHitReq.value) {
|
||||||
|
return `在最后的${validRhythmShootTime.value}秒内射箭`;
|
||||||
|
}
|
||||||
|
return `在最后的${validRhythmShootTime.value}秒并命中${validRhythmHitReq.value}环内`;
|
||||||
|
});
|
||||||
|
|
||||||
const displayName = computed(() => {
|
const displayName = computed(() => {
|
||||||
return (
|
return (
|
||||||
user.value?.nickName ||
|
user.value?.nickName ||
|
||||||
@@ -143,8 +250,147 @@ const clearTimer = () => {
|
|||||||
timer.value = null;
|
timer.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearRhythmTimers = () => {
|
||||||
|
if (rhythmCountdownTimer) {
|
||||||
|
clearTimeout(rhythmCountdownTimer);
|
||||||
|
rhythmCountdownTimer = null;
|
||||||
|
}
|
||||||
|
if (rhythmTransitionTimer) {
|
||||||
|
clearTimeout(rhythmTransitionTimer);
|
||||||
|
rhythmTransitionTimer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRhythmServerNow = () => Date.now() + rhythmServerClockOffsetMs;
|
||||||
|
|
||||||
|
const getRhythmRoundRemainingMs = () => {
|
||||||
|
const shootWindowStart = normalizeTimestamp(props.shootWindowStart);
|
||||||
|
if (!shootWindowStart || !rhythmRoundDurationMs.value) return 0;
|
||||||
|
|
||||||
|
const firstRoundEnd = shootWindowStart + rhythmShootDurationMs.value;
|
||||||
|
const serverNow = getRhythmServerNow();
|
||||||
|
let roundEnd = firstRoundEnd;
|
||||||
|
|
||||||
|
// 服务端锚点过期后继续按 round_time 推演下一轮,避免进度停在 0。
|
||||||
|
if (serverNow >= firstRoundEnd) {
|
||||||
|
const elapsedRounds =
|
||||||
|
Math.floor(
|
||||||
|
(serverNow - firstRoundEnd) / rhythmRoundDurationMs.value
|
||||||
|
) + 1;
|
||||||
|
roundEnd = firstRoundEnd + elapsedRounds * rhythmRoundDurationMs.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(rhythmRoundDurationMs.value, roundEnd - serverNow)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateRhythmShootWindow = (isInWindow) => {
|
||||||
|
const nextIsInWindow = Boolean(isInWindow);
|
||||||
|
const enteredShootWindow =
|
||||||
|
nextIsInWindow && !rhythmIsShootWindow.value;
|
||||||
|
rhythmIsShootWindow.value = nextIsInWindow;
|
||||||
|
|
||||||
|
// 只在进入窗口的边沿立即提示;预热和播放均为异步,不阻塞倒计时。
|
||||||
|
if (enteredShootWindow && props.start && isRhythmTraining.value) {
|
||||||
|
audioManager.play(RHYTHM_SHOOT_WINDOW_AUDIO_KEY);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 每秒只更新一次目标宽度,实际推进交给 CSS transition,减少响应式刷新。
|
||||||
|
const scheduleRhythmCountdownStep = () => {
|
||||||
|
rhythmCountdownTimer = null;
|
||||||
|
const currentRemaining = getRhythmRoundRemainingMs();
|
||||||
|
if (currentRemaining <= 0) {
|
||||||
|
rhythmTransitionStyle.value = "none";
|
||||||
|
rhythmRemainingMs.value = 0;
|
||||||
|
rhythmRemainingSeconds.value = 0;
|
||||||
|
updateRhythmShootWindow(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 数字展示使用服务端校准后的真实剩余时间,不读取提前写入的动画目标值。
|
||||||
|
rhythmRemainingSeconds.value = Math.ceil(currentRemaining / 1000);
|
||||||
|
updateRhythmShootWindow(
|
||||||
|
currentRemaining <= rhythmShootDurationMs.value
|
||||||
|
);
|
||||||
|
|
||||||
|
// 一轮结束后先无动画恢复至 100%,再开始下一轮向左递减。
|
||||||
|
if (currentRemaining - rhythmRemainingMs.value > 1000) {
|
||||||
|
rhythmTransitionStyle.value = "none";
|
||||||
|
rhythmRemainingMs.value = currentRemaining;
|
||||||
|
rhythmTransitionTimer = setTimeout(() => {
|
||||||
|
rhythmTransitionTimer = null;
|
||||||
|
scheduleRhythmCountdownStep();
|
||||||
|
}, 50);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRemaining = Math.max(
|
||||||
|
0,
|
||||||
|
(Math.ceil(currentRemaining / 1000) - 1) * 1000
|
||||||
|
);
|
||||||
|
const stepDuration = Math.max(50, currentRemaining - targetRemaining);
|
||||||
|
rhythmTransitionStyle.value = `width ${stepDuration}ms linear`;
|
||||||
|
rhythmRemainingMs.value = targetRemaining;
|
||||||
|
rhythmCountdownTimer = setTimeout(
|
||||||
|
scheduleRhythmCountdownStep,
|
||||||
|
stepDuration
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncRhythmCountdown = async () => {
|
||||||
|
const generation = ++rhythmSyncGeneration;
|
||||||
|
clearRhythmTimers();
|
||||||
|
|
||||||
|
if (!isRhythmTraining.value) {
|
||||||
|
rhythmTransitionStyle.value = "none";
|
||||||
|
rhythmRemainingMs.value = 0;
|
||||||
|
rhythmRemainingSeconds.value = 0;
|
||||||
|
updateRhythmShootWindow(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverTimestamp = normalizeTimestamp(props.serverTimestamp);
|
||||||
|
rhythmServerClockOffsetMs = serverTimestamp
|
||||||
|
? serverTimestamp - Date.now()
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!validRhythmRoundTime.value ||
|
||||||
|
!validRhythmShootTime.value ||
|
||||||
|
!normalizeTimestamp(props.shootWindowStart)
|
||||||
|
) {
|
||||||
|
rhythmTransitionStyle.value = "none";
|
||||||
|
rhythmRemainingMs.value = 0;
|
||||||
|
rhythmRemainingSeconds.value = 0;
|
||||||
|
updateRhythmShootWindow(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
rhythmTransitionStyle.value = "none";
|
||||||
|
rhythmRemainingMs.value = getRhythmRoundRemainingMs();
|
||||||
|
rhythmRemainingSeconds.value = Math.ceil(rhythmRemainingMs.value / 1000);
|
||||||
|
updateRhythmShootWindow(
|
||||||
|
rhythmRemainingMs.value > 0 &&
|
||||||
|
rhythmRemainingMs.value <= rhythmShootDurationMs.value
|
||||||
|
);
|
||||||
|
await nextTick();
|
||||||
|
if (generation !== rhythmSyncGeneration) return;
|
||||||
|
|
||||||
|
rhythmTransitionTimer = setTimeout(() => {
|
||||||
|
rhythmTransitionTimer = null;
|
||||||
|
scheduleRhythmCountdownStep();
|
||||||
|
}, 50);
|
||||||
|
};
|
||||||
|
|
||||||
const resetTimer = (count) => {
|
const resetTimer = (count) => {
|
||||||
clearTimer();
|
clearTimer();
|
||||||
|
if (isRhythmTraining.value) {
|
||||||
|
remain.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!props.countdownEnabled) {
|
if (!props.countdownEnabled) {
|
||||||
remain.value = 0;
|
remain.value = 0;
|
||||||
return;
|
return;
|
||||||
@@ -177,9 +423,12 @@ const resetTimer = (count) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.start, props.countdownEnabled],
|
() => [props.start, props.countdownEnabled, props.trainingType],
|
||||||
([started, countdownEnabled]) => {
|
([started, countdownEnabled, trainingType]) => {
|
||||||
if (started && countdownEnabled) {
|
if (trainingType === "rhythm") {
|
||||||
|
clearTimer();
|
||||||
|
remain.value = 0;
|
||||||
|
} else if (started && countdownEnabled) {
|
||||||
resetTimer(props.total);
|
resetTimer(props.total);
|
||||||
} else {
|
} else {
|
||||||
clearTimer();
|
clearTimer();
|
||||||
@@ -191,6 +440,18 @@ watch(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [
|
||||||
|
props.trainingType,
|
||||||
|
props.roundTime,
|
||||||
|
props.shootTime,
|
||||||
|
props.shootWindowStart,
|
||||||
|
props.serverTimestamp,
|
||||||
|
],
|
||||||
|
syncRhythmCountdown,
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
const tipContent = computed(() => {
|
const tipContent = computed(() => {
|
||||||
if (halfTime.value) {
|
if (halfTime.value) {
|
||||||
return props.battleId ? "中场休息" : `中场休息(${wait.value}秒)`;
|
return props.battleId ? "中场休息" : `中场休息(${wait.value}秒)`;
|
||||||
@@ -214,7 +475,7 @@ async function onReceiveMessage(msg) {
|
|||||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||||
audioManager.play("练习结束", false);
|
audioManager.play("练习结束", false);
|
||||||
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
|
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
|
||||||
// 精准训练由页面统一等待语音和飞箭结束,其他训练保持原播放链路。
|
// 精准和节奏训练由页面统一处理专属结果语音,其他训练保持原播放链路。
|
||||||
if (props.externalShootResultAudio) return;
|
if (props.externalShootResultAudio) return;
|
||||||
const latestDetail =
|
const latestDetail =
|
||||||
Array.isArray(msg.details) && msg.details.length > 0
|
Array.isArray(msg.details) && msg.details.length > 0
|
||||||
@@ -267,12 +528,41 @@ onBeforeUnmount(() => {
|
|||||||
uni.$off("socket-inbox", onReceiveMessage);
|
uni.$off("socket-inbox", onReceiveMessage);
|
||||||
uni.$off("play-sound", playSound);
|
uni.$off("play-sound", playSound);
|
||||||
clearTimer();
|
clearTimer();
|
||||||
|
rhythmSyncGeneration += 1;
|
||||||
|
clearRhythmTimers();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<view v-if="show" class="progress-card">
|
<view
|
||||||
<view class="progress-card__header">
|
v-if="show"
|
||||||
|
:class="isRhythmTraining ? 'rhythm-progress' : 'progress-card'"
|
||||||
|
>
|
||||||
|
<template v-if="isRhythmTraining">
|
||||||
|
<text class="rhythm-progress__title">{{ rhythmTitle }}</text>
|
||||||
|
<view class="rhythm-progress__track">
|
||||||
|
<view
|
||||||
|
class="rhythm-progress__fill"
|
||||||
|
:class="{
|
||||||
|
'rhythm-progress__fill--shooting': rhythmIsShootWindow,
|
||||||
|
}"
|
||||||
|
:style="{
|
||||||
|
width: `${rhythmProgressPercent}%`,
|
||||||
|
transition: rhythmTransitionStyle,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<view
|
||||||
|
class="rhythm-progress__marker"
|
||||||
|
:style="{ left: `${rhythmMarkerPercent}%` }"
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
v-if="rhythmRemainingSeconds > 0"
|
||||||
|
class="rhythm-progress__window-label"
|
||||||
|
>{{ rhythmRemainingSeconds }}秒</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<view class="progress-card__header">
|
||||||
<view class="progress-card__profile">
|
<view class="progress-card__profile">
|
||||||
<view class="progress-card__avatar-shell">
|
<view class="progress-card__avatar-shell">
|
||||||
<Avatar
|
<Avatar
|
||||||
@@ -304,16 +594,16 @@ onBeforeUnmount(() => {
|
|||||||
mode="aspectFit"
|
mode="aspectFit"
|
||||||
/>
|
/>
|
||||||
</button> -->
|
</button> -->
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="progress-card__track-wrap">
|
<view class="progress-card__track-wrap">
|
||||||
<image
|
<image
|
||||||
class="progress-card__titile"
|
class="progress-card__titile"
|
||||||
:src="trainingTitleIcon"
|
:src="trainingTitleIcon"
|
||||||
mode="aspectFit"
|
mode="aspectFit"
|
||||||
/>
|
/>
|
||||||
<view v-if="countdownEnabled" class="progress-card__track">
|
<view v-if="countdownEnabled" class="progress-card__track">
|
||||||
<view
|
<view
|
||||||
class="progress-card__fill"
|
class="progress-card__fill"
|
||||||
:style="{
|
:style="{
|
||||||
width: `${progressPercent}%`,
|
width: `${progressPercent}%`,
|
||||||
@@ -321,13 +611,14 @@ onBeforeUnmount(() => {
|
|||||||
right: tips.includes('红队') ? 0 : 'unset',
|
right: tips.includes('红队') ? 0 : 'unset',
|
||||||
transition: transitionStyle,
|
transition: transitionStyle,
|
||||||
}"
|
}"
|
||||||
/>
|
/>
|
||||||
<view class="progress-card__badge">
|
<view class="progress-card__badge">
|
||||||
<text class="progress-card__badge-text">剩余{{ remain }}秒</text>
|
<text class="progress-card__badge-text">剩余{{ remain }}秒</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<!-- <text v-if="tipContent" class="progress-card__tip">{{ tipContent }}123</text> -->
|
||||||
</view>
|
</view>
|
||||||
<!-- <text v-if="tipContent" class="progress-card__tip">{{ tipContent }}123</text> -->
|
</template>
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -466,4 +757,69 @@ onBeforeUnmount(() => {
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rhythm-progress {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 32rpx 84rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rhythm-progress__title {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 42rpx;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rhythm-progress__track {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 24rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: #444444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rhythm-progress__fill {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
border-radius: 18rpx;
|
||||||
|
background: linear-gradient(133deg, #ffd19a 0%, #a17636 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rhythm-progress__fill--shooting {
|
||||||
|
background: linear-gradient(90deg, #e2bd2e 0%, #fff047 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rhythm-progress__marker {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 2;
|
||||||
|
width: 4rpx;
|
||||||
|
transform: translateX(-2rpx);
|
||||||
|
background: rgba(26, 24, 22, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rhythm-progress__window-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 3;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
color: #fff7de;
|
||||||
|
font-size: 18rpx;
|
||||||
|
line-height: 24rpx;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -601,6 +601,8 @@ const createPracticeQuery = (difficulty) => {
|
|||||||
rhythm: {
|
rhythm: {
|
||||||
hitReq: toNumber(difficulty.hit_req),
|
hitReq: toNumber(difficulty.hit_req),
|
||||||
mode: toNumber(difficulty.mode),
|
mode: toNumber(difficulty.mode),
|
||||||
|
roundTime: toNumber(difficulty.round_time ?? difficulty.roundTime),
|
||||||
|
shootTime: toNumber(difficulty.shoot_time ?? difficulty.shootTime),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -634,6 +636,8 @@ const saveTrainingContext = (practice = {}) => {
|
|||||||
difficultyLabel: difficulty.label,
|
difficultyLabel: difficulty.label,
|
||||||
targetType: defaultTargetType,
|
targetType: defaultTargetType,
|
||||||
targetPaperType: difficulty.targetPaperType,
|
targetPaperType: difficulty.targetPaperType,
|
||||||
|
roundTime: toNumber(difficulty.round_time ?? difficulty.roundTime),
|
||||||
|
shootTime: toNumber(difficulty.shoot_time ?? difficulty.shootTime),
|
||||||
practiceId: practice.id || "",
|
practiceId: practice.id || "",
|
||||||
serverAddr: practice.serverAddr || "",
|
serverAddr: practice.serverAddr || "",
|
||||||
createdAt: practice.id ? Date.now() : 0,
|
createdAt: practice.id ? Date.now() : 0,
|
||||||
|
|||||||
@@ -124,20 +124,21 @@ const formatValue = (value, digits = 1) => {
|
|||||||
return String(Number(numberValue.toFixed(digits)));
|
return String(Number(numberValue.toFixed(digits)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatCompactCount = (value) => {
|
||||||
|
const numberValue = Number(value);
|
||||||
|
if (!Number.isFinite(numberValue)) return "--";
|
||||||
|
if (numberValue >= 10000) return `${formatValue(numberValue / 1000)}K`;
|
||||||
|
return formatValue(numberValue, 0);
|
||||||
|
};
|
||||||
|
|
||||||
const getLevelText = (item) => {
|
const getLevelText = (item) => {
|
||||||
if (!item) return "";
|
if (!item) return "";
|
||||||
const level = Number(item.current_level) || 0;
|
const level = Number(item.current_level) || 0;
|
||||||
return `当前进度 LV${level} >`;
|
return `当前进度 LV${level} >`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 卡路里字段按需求做 K / W 缩写展示。
|
// 接口字段暂时沿用 total_calories,页面按平均环数展示。
|
||||||
const getCaloriesValue = (value) => {
|
const getAverageRingValue = (value) => formatValue(value);
|
||||||
const numberValue = Number(value);
|
|
||||||
if (!Number.isFinite(numberValue)) return "--";
|
|
||||||
if (numberValue >= 10000) return `${formatValue(numberValue / 10000)}W`;
|
|
||||||
if (numberValue >= 1000) return `${formatValue(numberValue / 1000)}K`;
|
|
||||||
return formatValue(numberValue, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getTrainingIcon = (item = {}) =>
|
const getTrainingIcon = (item = {}) =>
|
||||||
trainingModeIconMap[item.icon] || trainingModeIconMap.bow;
|
trainingModeIconMap[item.icon] || trainingModeIconMap.bow;
|
||||||
@@ -418,7 +419,7 @@ onShow(async () => {
|
|||||||
<view class="stats-value-row">
|
<view class="stats-value-row">
|
||||||
<view class="stats-value-group">
|
<view class="stats-value-group">
|
||||||
<text class="stats-value">
|
<text class="stats-value">
|
||||||
{{ formatValue(trainingData.stats.total_arrows, 0) }}
|
{{ formatCompactCount(trainingData.stats.total_arrows) }}
|
||||||
</text>
|
</text>
|
||||||
<text class="stats-unit">支</text>
|
<text class="stats-unit">支</text>
|
||||||
<view class="stats-value-decoration"></view>
|
<view class="stats-value-decoration"></view>
|
||||||
@@ -444,7 +445,7 @@ onShow(async () => {
|
|||||||
<view class="stats-value-row">
|
<view class="stats-value-row">
|
||||||
<view class="stats-value-group">
|
<view class="stats-value-group">
|
||||||
<text class="stats-value">
|
<text class="stats-value">
|
||||||
{{ formatValue(trainingData.stats.ten_ring_count, 0) }}
|
{{ formatCompactCount(trainingData.stats.ten_ring_count) }}
|
||||||
</text>
|
</text>
|
||||||
<text class="stats-unit">支</text>
|
<text class="stats-unit">支</text>
|
||||||
<view class="stats-value-decoration"></view>
|
<view class="stats-value-decoration"></view>
|
||||||
@@ -457,13 +458,13 @@ onShow(async () => {
|
|||||||
<view class="stats-value-row">
|
<view class="stats-value-row">
|
||||||
<view class="stats-value-group">
|
<view class="stats-value-group">
|
||||||
<text class="stats-value">
|
<text class="stats-value">
|
||||||
{{ getCaloriesValue(trainingData.stats.total_calories) }}
|
{{ getAverageRingValue(trainingData.stats.total_calories) }}
|
||||||
</text>
|
</text>
|
||||||
<text class="stats-unit">卡路里</text>
|
<text class="stats-unit">环</text>
|
||||||
<view class="stats-value-decoration"></view>
|
<view class="stats-value-decoration"></view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<text class="stats-label">共消耗</text>
|
<text class="stats-label">平均环数</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import TestDistance from "./components/TestDistance.vue";
|
|||||||
import BubbleTip from "./components/BubbleTip.vue";
|
import BubbleTip from "./components/BubbleTip.vue";
|
||||||
import audioManager, {
|
import audioManager, {
|
||||||
getPrecisionShotAudioKeys,
|
getPrecisionShotAudioKeys,
|
||||||
|
getRhythmShotAudioKeys,
|
||||||
getTrainingStartAudioKey,
|
getTrainingStartAudioKey,
|
||||||
|
RHYTHM_SHOOT_WINDOW_AUDIO_KEY,
|
||||||
} from "@/audioManager";
|
} from "@/audioManager";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -26,12 +28,13 @@ import {
|
|||||||
import {
|
import {
|
||||||
connectMatchWebSocket,
|
connectMatchWebSocket,
|
||||||
closeMatchWebSocket,
|
closeMatchWebSocket,
|
||||||
|
requestPracticeInfoSync,
|
||||||
setMatchAppHideResumable,
|
setMatchAppHideResumable,
|
||||||
MATCH_WS_PRACTICE_SYNC_EVENT,
|
MATCH_WS_PRACTICE_SYNC_EVENT,
|
||||||
MATCH_WS_STATE_EVENT,
|
MATCH_WS_STATE_EVENT,
|
||||||
} from "@/matchWebsocket";
|
} from "@/matchWebsocket";
|
||||||
import { sharePractiseData } from "@/canvas";
|
import { sharePractiseData } from "@/canvas";
|
||||||
import { wxShare, debounce, getDirectionText } from "@/util";
|
import { wxShare, debounce, getDirectionText, capsuleHeight } from "@/util";
|
||||||
import { MESSAGETYPESV2, roundsName } from "@/constants";
|
import { MESSAGETYPESV2, roundsName } from "@/constants";
|
||||||
|
|
||||||
import useStore from "@/store";
|
import useStore from "@/store";
|
||||||
@@ -50,6 +53,25 @@ const pageStages = Object.freeze({
|
|||||||
});
|
});
|
||||||
const pageStage = ref(pageStages.LOADING);
|
const pageStage = ref(pageStages.LOADING);
|
||||||
const scores = ref([]);
|
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 新增一箭时递增,避免同步快照重播飞箭特效。
|
// 只在实时 ShootResult 新增一箭时递增,避免同步快照重播飞箭特效。
|
||||||
const shotEffectToken = ref(0);
|
const shotEffectToken = ref(0);
|
||||||
// 可见区域每次正式提交都递增,同一区域连续刷新也能触发动效。
|
// 可见区域每次正式提交都递增,同一区域连续刷新也能触发动效。
|
||||||
@@ -66,6 +88,10 @@ const tips = ref("");
|
|||||||
const targetType = ref(defaultTargetType);
|
const targetType = ref(defaultTargetType);
|
||||||
const trainingParams = ref({});
|
const trainingParams = ref({});
|
||||||
const practiceInfo = ref({});
|
const practiceInfo = ref({});
|
||||||
|
const rhythmServerTimestamp = ref(0);
|
||||||
|
const rhythmFallbackWindowStart = ref(0);
|
||||||
|
const rhythmFallbackTimestamp = ref(0);
|
||||||
|
const rhythmHasActiveServerAnchor = ref(false);
|
||||||
// 服务端状态立即落到 practiceInfo,精准训练的目标区域单独延迟展示。
|
// 服务端状态立即落到 practiceInfo,精准训练的目标区域单独延迟展示。
|
||||||
const visiblePrecisionTarget = ref({
|
const visiblePrecisionTarget = ref({
|
||||||
randomBlock: 0,
|
randomBlock: 0,
|
||||||
@@ -236,6 +262,55 @@ const loadNextDifficultyState = (result = {}) => {
|
|||||||
// time_limit 缺失或非正数都表示整局不限时。
|
// time_limit 缺失或非正数都表示整局不限时。
|
||||||
const timeLimit = computed(() => getPositiveInteger(practiceInfo.value.timeLimit));
|
const timeLimit = computed(() => getPositiveInteger(practiceInfo.value.timeLimit));
|
||||||
const hasTimeLimit = computed(() => timeLimit.value > 0);
|
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(() => {
|
const precisionBlocks = computed(() => {
|
||||||
if (useHighlightTest.value) {
|
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;
|
return null;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -373,6 +475,10 @@ const practiceInfoFields = [
|
|||||||
"blocks",
|
"blocks",
|
||||||
"randomBlock",
|
"randomBlock",
|
||||||
"randomRingArea",
|
"randomRingArea",
|
||||||
|
"roundTime",
|
||||||
|
"shootTime",
|
||||||
|
"shootWindowStart",
|
||||||
|
"inShootWindow",
|
||||||
"timeLimit",
|
"timeLimit",
|
||||||
"completed",
|
"completed",
|
||||||
"totalArrows",
|
"totalArrows",
|
||||||
@@ -425,7 +531,46 @@ const practiceResultFields = [
|
|||||||
"details",
|
"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 = {}) => {
|
const syncPracticeInfo = (message = {}) => {
|
||||||
|
cacheRhythmTrainingConfig(message);
|
||||||
const nextInfo = practiceInfoFields.reduce((result, field) => {
|
const nextInfo = practiceInfoFields.reduce((result, field) => {
|
||||||
if (Object.prototype.hasOwnProperty.call(message, field)) {
|
if (Object.prototype.hasOwnProperty.call(message, field)) {
|
||||||
result[field] = message[field];
|
result[field] = message[field];
|
||||||
@@ -477,6 +622,23 @@ const buildShootResultAudioKeys = (message = {}) => {
|
|||||||
return getPrecisionShotAudioKeys(arrow, directionText);
|
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 playAudioKeysAndWait = (keys) => {
|
||||||
const audioKeys = (Array.isArray(keys) ? keys : [keys]).filter(Boolean);
|
const audioKeys = (Array.isArray(keys) ? keys : [keys]).filter(Boolean);
|
||||||
if (audioKeys.length === 0) return Promise.resolve();
|
if (audioKeys.length === 0) return Promise.resolve();
|
||||||
@@ -702,6 +864,16 @@ const onPracticeInfoSync = (payload = {}) => {
|
|||||||
|
|
||||||
const snapshot = payload.practiceInfo;
|
const snapshot = payload.practiceInfo;
|
||||||
if (!snapshot || typeof snapshot !== "object") return;
|
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 =
|
const shouldShowDistance =
|
||||||
waitingPracticeSync && pageStage.value === pageStages.LOADING;
|
waitingPracticeSync && pageStage.value === pageStages.LOADING;
|
||||||
@@ -1019,6 +1191,14 @@ onLoad((options = {}) => {
|
|||||||
totalReq: toRouteNumber(options.totalReq),
|
totalReq: toRouteNumber(options.totalReq),
|
||||||
blocks: toRouteNumber(options.blocks),
|
blocks: toRouteNumber(options.blocks),
|
||||||
mode: toRouteNumber(options.mode),
|
mode: toRouteNumber(options.mode),
|
||||||
|
roundTime: toRouteNumber(
|
||||||
|
options.roundTime,
|
||||||
|
toRouteNumber(trainingContext.roundTime)
|
||||||
|
),
|
||||||
|
shootTime: toRouteNumber(
|
||||||
|
options.shootTime,
|
||||||
|
toRouteNumber(trainingContext.shootTime)
|
||||||
|
),
|
||||||
};
|
};
|
||||||
practiseId.value = trainingContext.practiceId || "";
|
practiseId.value = trainingContext.practiceId || "";
|
||||||
serverAddr.value = trainingContext.serverAddr || "";
|
serverAddr.value = trainingContext.serverAddr || "";
|
||||||
@@ -1027,6 +1207,8 @@ onLoad((options = {}) => {
|
|||||||
const warmupKeys = [startAudioKey];
|
const warmupKeys = [startAudioKey];
|
||||||
if (trainingParams.value.type === "precision") {
|
if (trainingParams.value.type === "precision") {
|
||||||
warmupKeys.push("Bingo命中目标", "未命中");
|
warmupKeys.push("Bingo命中目标", "未命中");
|
||||||
|
} else if (trainingParams.value.type === "rhythm") {
|
||||||
|
warmupKeys.push(RHYTHM_SHOOT_WINDOW_AUDIO_KEY, "Perfect", "miss");
|
||||||
}
|
}
|
||||||
void audioManager.warmKeys(warmupKeys.filter(Boolean));
|
void audioManager.warmKeys(warmupKeys.filter(Boolean));
|
||||||
});
|
});
|
||||||
@@ -1057,9 +1239,12 @@ const onReady = async () => {
|
|||||||
practiseResult.value = {};
|
practiseResult.value = {};
|
||||||
scores.value = [];
|
scores.value = [];
|
||||||
shotEffectToken.value = 0;
|
shotEffectToken.value = 0;
|
||||||
|
initializeRhythmFirstRoundCountdown();
|
||||||
start.value = true;
|
start.value = true;
|
||||||
pageStage.value = pageStages.SHOOTING;
|
pageStage.value = pageStages.SHOOTING;
|
||||||
setPracticeAppHideResumable(true);
|
setPracticeAppHideResumable(true);
|
||||||
|
// 先由本地锚点保证首轮立即显示,再用最新服务端快照无感校准。
|
||||||
|
requestPracticeInfoSync();
|
||||||
// 开始接口成功即进入正式训练,直接播放对应提示,避免依赖 BattleStart 消息。
|
// 开始接口成功即进入正式训练,直接播放对应提示,避免依赖 BattleStart 消息。
|
||||||
audioManager.play(
|
audioManager.play(
|
||||||
getTrainingStartAudioKey(trainingType.value, "练习开始")
|
getTrainingStartAudioKey(trainingType.value, "练习开始")
|
||||||
@@ -1125,7 +1310,27 @@ const onOver = async () => {
|
|||||||
|
|
||||||
async function onReceiveMessage(msg) {
|
async function onReceiveMessage(msg) {
|
||||||
const previousScoreLength = scores.value.length;
|
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);
|
syncPracticeInfo(msg);
|
||||||
|
if (shouldSyncRhythmAnchor) {
|
||||||
|
rhythmHasActiveServerAnchor.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === MESSAGETYPESV2.BattleStart) {
|
if (msg.type === MESSAGETYPESV2.BattleStart) {
|
||||||
invalidateShotPresentations();
|
invalidateShotPresentations();
|
||||||
@@ -1164,6 +1369,14 @@ async function onReceiveMessage(msg) {
|
|||||||
audioPromise,
|
audioPromise,
|
||||||
effectPromise,
|
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) {
|
} else if (hasNewShot) {
|
||||||
shotEffectToken.value += 1;
|
shotEffectToken.value += 1;
|
||||||
}
|
}
|
||||||
@@ -1210,6 +1423,10 @@ async function onRetry() {
|
|||||||
practiseResult.value = {};
|
practiseResult.value = {};
|
||||||
practiceEndSnapshot.value = {};
|
practiceEndSnapshot.value = {};
|
||||||
practiceInfo.value = {};
|
practiceInfo.value = {};
|
||||||
|
rhythmServerTimestamp.value = 0;
|
||||||
|
rhythmFallbackWindowStart.value = 0;
|
||||||
|
rhythmFallbackTimestamp.value = 0;
|
||||||
|
rhythmHasActiveServerAnchor.value = false;
|
||||||
invalidateShotPresentations({ resetVisible: true });
|
invalidateShotPresentations({ resetVisible: true });
|
||||||
start.value = false;
|
start.value = false;
|
||||||
scores.value = [];
|
scores.value = [];
|
||||||
@@ -1332,6 +1549,10 @@ onBeforeUnmount(() => {
|
|||||||
:showBottom="isDistanceStage"
|
:showBottom="isDistanceStage"
|
||||||
:scroll="!isShootingStage"
|
:scroll="!isShootingStage"
|
||||||
:onBack="exitPractice"
|
:onBack="exitPractice"
|
||||||
|
:title="isShootingStage && isRhythmTraining ? '节奏训练' : ''"
|
||||||
|
:titleStyle="
|
||||||
|
isShootingStage && isRhythmTraining ? rhythmHeaderTitleStyle : {}
|
||||||
|
"
|
||||||
>
|
>
|
||||||
<view class="practise-content">
|
<view class="practise-content">
|
||||||
<TestDistance
|
<TestDistance
|
||||||
@@ -1347,9 +1568,17 @@ onBeforeUnmount(() => {
|
|||||||
:total="timeLimit"
|
:total="timeLimit"
|
||||||
:countdownEnabled="hasTimeLimit"
|
:countdownEnabled="hasTimeLimit"
|
||||||
:trainingType="trainingType"
|
:trainingType="trainingType"
|
||||||
|
:roundTime="rhythmRoundTime"
|
||||||
|
:shootTime="rhythmShootTime"
|
||||||
|
:shootWindowStart="rhythmShootWindowStart"
|
||||||
|
:inShootWindow="rhythmInShootWindow"
|
||||||
|
:serverTimestamp="rhythmCountdownTimestamp"
|
||||||
|
:hitReq="rhythmHitReq"
|
||||||
:isVip="isVip"
|
:isVip="isVip"
|
||||||
:isSvip="isSvip"
|
:isSvip="isSvip"
|
||||||
:externalShootResultAudio="trainingType === 'precision'"
|
:externalShootResultAudio="
|
||||||
|
trainingType === 'precision' || trainingType === 'rhythm'
|
||||||
|
"
|
||||||
:onStop="onTimeLimitReached"
|
:onStop="onTimeLimitReached"
|
||||||
/>
|
/>
|
||||||
<view class="user-row">
|
<view class="user-row">
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
Reference in New Issue
Block a user