update:优化节奏训练
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
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 = "请射箭";
|
||||
|
||||
const TRAINING_START_AUDIO_KEY_MAP = Object.freeze({
|
||||
base: "请于计时前完成指定环数对应箭量",
|
||||
endurance: "请于计时前完成累计环数和箭量",
|
||||
precision: "请射箭命中高亮区域",
|
||||
rhythm: "请在读条推进到标记区间内射箭",
|
||||
});
|
||||
|
||||
export const getTrainingStartAudioKey = (trainingType, fallback = "") =>
|
||||
@@ -28,6 +30,17 @@ export const getPrecisionShotAudioKeys = (shootData, directionText = "") => {
|
||||
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 = {
|
||||
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",
|
||||
@@ -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%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命中目标:
|
||||
"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";
|
||||
import {
|
||||
getPrecisionShotAudioKeys,
|
||||
getRhythmShotAudioKeys,
|
||||
getTrainingStartAudioKey,
|
||||
} from "@/audioManager";
|
||||
import {
|
||||
@@ -405,10 +406,9 @@ function getAckAudioKeys(message, businessMessage) {
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
if (
|
||||
(businessMessage?.trainingType || currentContext?.trainingType) ===
|
||||
"precision"
|
||||
) {
|
||||
const trainingType =
|
||||
businessMessage?.trainingType || currentContext?.trainingType;
|
||||
if (trainingType === "precision") {
|
||||
const shootData = businessMessage?.shootData;
|
||||
const directionText =
|
||||
shootData?.angle !== null && shootData?.angle !== undefined
|
||||
@@ -416,6 +416,9 @@ function getAckAudioKeys(message, businessMessage) {
|
||||
: "";
|
||||
return getPrecisionShotAudioKeys(shootData, directionText);
|
||||
}
|
||||
if (trainingType === "rhythm") {
|
||||
return getRhythmShotAudioKeys(businessMessage?.shootData);
|
||||
}
|
||||
return getShootResultAudioKeys(businessMessage?.shootData);
|
||||
case ServerMessageType.SERVER_MSG_MATCH_END:
|
||||
return ["比赛结束"];
|
||||
@@ -680,7 +683,9 @@ function sendHeartbeatAck() {
|
||||
}
|
||||
|
||||
function sendPracticeInfoSync() {
|
||||
if (!socket || !currentContext?.matchId || !currentContext?.userId) return;
|
||||
if (!socket || !currentContext?.matchId || !currentContext?.userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const clientMessage = {
|
||||
type: ClientMessageType.CLIENT_MSG_SYNC_PRACTICE_INFO,
|
||||
@@ -695,6 +700,12 @@ function sendPracticeInfoSync() {
|
||||
"CLIENT_MSG_SYNC_PRACTICE_INFO",
|
||||
clientMessage
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 训练开始后可在不重连 WebSocket 的情况下主动刷新完整练习快照。
|
||||
export function requestPracticeInfoSync() {
|
||||
return sendPracticeInfoSync();
|
||||
}
|
||||
|
||||
function sendAck({ matchId, sequence }) {
|
||||
|
||||
@@ -54,6 +54,18 @@ const showComment = ref(false);
|
||||
const showBowData = 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 = () => {
|
||||
showPanel.value = false;
|
||||
setTimeout(() => {
|
||||
@@ -321,13 +333,13 @@ const calories = computed(
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<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>
|
||||
|
||||
<view class="result-panel">
|
||||
<view :class="['result-panel', { 'result-panel--fail': !isCompleted }]">
|
||||
<view class="line-top"></view>
|
||||
<view class="line-bottom"></view>
|
||||
<view class="stats">
|
||||
@@ -517,6 +529,10 @@ const calories = computed(
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.result-panel--fail {
|
||||
background: rgba(34, 34, 46, 0.8);
|
||||
}
|
||||
|
||||
.stats {
|
||||
width: 100%;
|
||||
margin-top: 34rpx;
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, onBeforeUnmount, computed } from "vue";
|
||||
import audioManager, { getTrainingStartAudioKey } from "@/audioManager";
|
||||
import {
|
||||
ref,
|
||||
watch,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
computed,
|
||||
nextTick,
|
||||
} from "vue";
|
||||
import audioManager, {
|
||||
getTrainingStartAudioKey,
|
||||
RHYTHM_SHOOT_WINDOW_AUDIO_KEY,
|
||||
} from "@/audioManager";
|
||||
import { MESSAGETYPESV2 } from "@/constants";
|
||||
import {
|
||||
getDirectionText,
|
||||
@@ -39,6 +49,30 @@ const props = defineProps({
|
||||
type: String,
|
||||
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: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -93,12 +127,85 @@ const currentRoundEnded = ref(false);
|
||||
const halfTime = ref(false);
|
||||
const wait = ref(0);
|
||||
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(() => {
|
||||
if (!props.countdownEnabled || !props.total) return 0;
|
||||
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(() => {
|
||||
return (
|
||||
user.value?.nickName ||
|
||||
@@ -143,8 +250,147 @@ const clearTimer = () => {
|
||||
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) => {
|
||||
clearTimer();
|
||||
if (isRhythmTraining.value) {
|
||||
remain.value = 0;
|
||||
return;
|
||||
}
|
||||
if (!props.countdownEnabled) {
|
||||
remain.value = 0;
|
||||
return;
|
||||
@@ -177,9 +423,12 @@ const resetTimer = (count) => {
|
||||
};
|
||||
|
||||
watch(
|
||||
() => [props.start, props.countdownEnabled],
|
||||
([started, countdownEnabled]) => {
|
||||
if (started && countdownEnabled) {
|
||||
() => [props.start, props.countdownEnabled, props.trainingType],
|
||||
([started, countdownEnabled, trainingType]) => {
|
||||
if (trainingType === "rhythm") {
|
||||
clearTimer();
|
||||
remain.value = 0;
|
||||
} else if (started && countdownEnabled) {
|
||||
resetTimer(props.total);
|
||||
} else {
|
||||
clearTimer();
|
||||
@@ -191,6 +440,18 @@ watch(
|
||||
}
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [
|
||||
props.trainingType,
|
||||
props.roundTime,
|
||||
props.shootTime,
|
||||
props.shootWindowStart,
|
||||
props.serverTimestamp,
|
||||
],
|
||||
syncRhythmCountdown,
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const tipContent = computed(() => {
|
||||
if (halfTime.value) {
|
||||
return props.battleId ? "中场休息" : `中场休息(${wait.value}秒)`;
|
||||
@@ -214,7 +475,7 @@ async function onReceiveMessage(msg) {
|
||||
} else if (msg.type === MESSAGETYPESV2.BattleEnd) {
|
||||
audioManager.play("练习结束", false);
|
||||
} else if (msg.type === MESSAGETYPESV2.ShootResult) {
|
||||
// 精准训练由页面统一等待语音和飞箭结束,其他训练保持原播放链路。
|
||||
// 精准和节奏训练由页面统一处理专属结果语音,其他训练保持原播放链路。
|
||||
if (props.externalShootResultAudio) return;
|
||||
const latestDetail =
|
||||
Array.isArray(msg.details) && msg.details.length > 0
|
||||
@@ -267,12 +528,41 @@ onBeforeUnmount(() => {
|
||||
uni.$off("socket-inbox", onReceiveMessage);
|
||||
uni.$off("play-sound", playSound);
|
||||
clearTimer();
|
||||
rhythmSyncGeneration += 1;
|
||||
clearRhythmTimers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<view v-if="show" class="progress-card">
|
||||
<view class="progress-card__header">
|
||||
<view
|
||||
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__avatar-shell">
|
||||
<Avatar
|
||||
@@ -304,16 +594,16 @@ onBeforeUnmount(() => {
|
||||
mode="aspectFit"
|
||||
/>
|
||||
</button> -->
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="progress-card__track-wrap">
|
||||
<image
|
||||
<view class="progress-card__track-wrap">
|
||||
<image
|
||||
class="progress-card__titile"
|
||||
:src="trainingTitleIcon"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<view v-if="countdownEnabled" class="progress-card__track">
|
||||
<view
|
||||
<view v-if="countdownEnabled" class="progress-card__track">
|
||||
<view
|
||||
class="progress-card__fill"
|
||||
:style="{
|
||||
width: `${progressPercent}%`,
|
||||
@@ -321,13 +611,14 @@ onBeforeUnmount(() => {
|
||||
right: tips.includes('红队') ? 0 : 'unset',
|
||||
transition: transitionStyle,
|
||||
}"
|
||||
/>
|
||||
<view class="progress-card__badge">
|
||||
<text class="progress-card__badge-text">剩余{{ remain }}秒</text>
|
||||
/>
|
||||
<view class="progress-card__badge">
|
||||
<text class="progress-card__badge-text">剩余{{ remain }}秒</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- <text v-if="tipContent" class="progress-card__tip">{{ tipContent }}123</text> -->
|
||||
</view>
|
||||
<!-- <text v-if="tipContent" class="progress-card__tip">{{ tipContent }}123</text> -->
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -466,4 +757,69 @@ onBeforeUnmount(() => {
|
||||
line-height: 1.4;
|
||||
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>
|
||||
|
||||
@@ -601,6 +601,8 @@ const createPracticeQuery = (difficulty) => {
|
||||
rhythm: {
|
||||
hitReq: toNumber(difficulty.hit_req),
|
||||
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,
|
||||
targetType: defaultTargetType,
|
||||
targetPaperType: difficulty.targetPaperType,
|
||||
roundTime: toNumber(difficulty.round_time ?? difficulty.roundTime),
|
||||
shootTime: toNumber(difficulty.shoot_time ?? difficulty.shootTime),
|
||||
practiceId: practice.id || "",
|
||||
serverAddr: practice.serverAddr || "",
|
||||
createdAt: practice.id ? Date.now() : 0,
|
||||
|
||||
@@ -124,20 +124,21 @@ const formatValue = (value, digits = 1) => {
|
||||
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) => {
|
||||
if (!item) return "";
|
||||
const level = Number(item.current_level) || 0;
|
||||
return `当前进度 LV${level} >`;
|
||||
};
|
||||
|
||||
// 卡路里字段按需求做 K / W 缩写展示。
|
||||
const getCaloriesValue = (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);
|
||||
};
|
||||
// 接口字段暂时沿用 total_calories,页面按平均环数展示。
|
||||
const getAverageRingValue = (value) => formatValue(value);
|
||||
|
||||
const getTrainingIcon = (item = {}) =>
|
||||
trainingModeIconMap[item.icon] || trainingModeIconMap.bow;
|
||||
@@ -418,7 +419,7 @@ onShow(async () => {
|
||||
<view class="stats-value-row">
|
||||
<view class="stats-value-group">
|
||||
<text class="stats-value">
|
||||
{{ formatValue(trainingData.stats.total_arrows, 0) }}
|
||||
{{ formatCompactCount(trainingData.stats.total_arrows) }}
|
||||
</text>
|
||||
<text class="stats-unit">支</text>
|
||||
<view class="stats-value-decoration"></view>
|
||||
@@ -444,7 +445,7 @@ onShow(async () => {
|
||||
<view class="stats-value-row">
|
||||
<view class="stats-value-group">
|
||||
<text class="stats-value">
|
||||
{{ formatValue(trainingData.stats.ten_ring_count, 0) }}
|
||||
{{ formatCompactCount(trainingData.stats.ten_ring_count) }}
|
||||
</text>
|
||||
<text class="stats-unit">支</text>
|
||||
<view class="stats-value-decoration"></view>
|
||||
@@ -457,13 +458,13 @@ onShow(async () => {
|
||||
<view class="stats-value-row">
|
||||
<view class="stats-value-group">
|
||||
<text class="stats-value">
|
||||
{{ getCaloriesValue(trainingData.stats.total_calories) }}
|
||||
{{ getAverageRingValue(trainingData.stats.total_calories) }}
|
||||
</text>
|
||||
<text class="stats-unit">卡路里</text>
|
||||
<text class="stats-unit">环</text>
|
||||
<view class="stats-value-decoration"></view>
|
||||
</view>
|
||||
</view>
|
||||
<text class="stats-label">共消耗</text>
|
||||
<text class="stats-label">平均环数</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -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">
|
||||
|
||||
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