update:优化节奏训练

This commit is contained in:
2026-08-24 16:35:56 +08:00
parent 59fa317c5a
commit 35e0ee3ca0
9 changed files with 679 additions and 41 deletions
+19 -3
View File
@@ -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;
+374 -18
View File
@@ -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>