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