3 Commits
Author SHA1 Message Date
zhangyi 9a06dcf942 update:对接节奏训练 2026-08-25 17:36:54 +08:00
zhangyi 35e0ee3ca0 update:优化节奏训练 2026-08-24 16:35:56 +08:00
zhangyi 59fa317c5a update: 优化个人训练 2026-08-21 15:38:01 +08:00
21 changed files with 800 additions and 140 deletions
+21
View File
@@ -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
View File
@@ -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 }) {
+28 -5
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(() => {
@@ -235,6 +247,13 @@ const metricConfigs = {
deltaKeys: ["deltaTotalRings", "delta_total_rings"],
deltaUnit: "环",
},
{
label: "十环次数",
valueKeys: ["tenRingCount", "ten_ring_count"],
unit: "次",
deltaKeys: ["deltaTenRingCount", "delta_ten_ring_count"],
deltaUnit: "次",
},
],
endurance: [
{
@@ -321,13 +340,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">
@@ -352,7 +371,7 @@ const calories = computed(
<view v-else class="stat-value">--</view>
</view>
</view>
<view class="stat-row">
<view v-if="resultTrainingType !== 'rhythm'" class="stat-row">
<image class="stat-bg" src="https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/result-c-bg.png" mode="scaleToFill" />
<view class="stat-cell">
<text class="stat-label">消耗卡路里</text>
@@ -435,7 +454,7 @@ const calories = computed(
</view>
</view>
</ScreenHint>
<BowData :total="arrows.length" :arrows="result.details" :show="showBowData"
<BowData :total="details.length" :arrows="details" :show="showBowData"
:onClose="() => (showBowData = false)" />
<UserUpgrade :show="showUpgrade" :onClose="() => (showUpgrade = false)" :lvl="userLevel" />
</view>
@@ -517,6 +536,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>
@@ -15,6 +15,10 @@ const props = defineProps({
type: Boolean,
default: false,
},
showUnlockProgress: {
type: Boolean,
default: false,
},
completedProgress: {
type: Number,
default: 0,
@@ -106,6 +110,15 @@ const handleClick = () => {
<view class="difficulty-badge__label">{{ node.label }}</view>
</view>
</view>
<view
v-if="showUnlockProgress"
class="difficulty-badge__unlock-progress"
>
<view
class="difficulty-badge__unlock-progress-completed"
:style="{ width: `${progressValue}%` }"
></view>
</view>
</view>
</template>
@@ -315,6 +328,25 @@ const handleClick = () => {
overflow: hidden;
}
.difficulty-badge__unlock-progress {
position: absolute;
top: calc(100% + 30rpx);
left: 50%;
width: 120rpx;
height: 10rpx;
overflow: hidden;
border-radius: 6rpx;
background: rgba(0, 0, 0, 0.6);
transform: translateX(-50%);
pointer-events: none;
}
.difficulty-badge__unlock-progress-completed {
height: 100%;
border-radius: 6rpx;
background: rgba(255, 217, 71, 1);
}
@keyframes badge-orbit-spin {
from {
transform: translate(-50%, -50%) rotate(0deg);
+9 -15
View File
@@ -83,10 +83,6 @@ const checkDifficultyCompleted = (item = {}) => {
return Boolean(item.completed) || (promoteCnt > 0 && completedCnt >= promoteCnt);
};
const getDifficultyModeText = (mode) => {
return Number(mode) === 1 ? "随机区域+指定环数" : "随机区域命中";
};
const createEmptyModeConfig = (type = defaultTrainingType) => {
const meta = trainingTypeMetaMap[type] || trainingTypeMetaMap[defaultTrainingType];
@@ -107,14 +103,12 @@ const createDifficultySummary = (item = {}) => {
const timeLimit = toNumber(item.time_limit);
const hitReq = toNumber(item.hit_req);
const totalReq = toNumber(item.total_req);
const promoteCnt = toNumber(item.promote_cnt);
const shootingTimeText =
timeLimit > 0 ? `在${timeLimit}秒内进行射箭` : "不限时进行射箭";
const enduranceTimeText =
timeLimit > 0
? `在${timeLimit}秒内完成${arrows}箭`
: `不限时完成${arrows}箭`;
const promoteText = promoteCnt > 0 ? `完成${promoteCnt}次晋级` : "";
const summaryMap = {
base: [
@@ -130,15 +124,10 @@ const createDifficultySummary = (item = {}) => {
`需要有${arrows}箭命中高亮区域`,
],
rhythm: [
desc || `间隔${timeLimit}秒射击`,
[
`${arrows}箭`,
hitReq > 0 ? `每箭${hitReq}环以上` : "上靶即可",
getDifficultyModeText(item.mode),
promoteText,
]
.filter(Boolean)
.join(" · "),
arrows > 0
? `需要在指定时间节点射出${arrows}射箭`
: "需要在指定时间节点射箭",
hitReq > 0 ? `且每箭命中${hitReq}环内` : "且每箭命中指定区域",
],
};
@@ -612,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),
},
};
@@ -645,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,
@@ -803,6 +796,7 @@ onShow(() => {
:node="node"
:active="node.id === selectedDifficultyId"
:locked="checkDifficultyLocked(node)"
:showUnlockProgress="node.id === unlockedDifficultyId"
:completedProgress="getCompletedDifficultyProgress(node)"
@click="handleSelectDifficulty"
/>
+54 -92
View File
@@ -15,9 +15,9 @@ const trainingModeRouteMap = {
endurance: "endurance",
precision: "precision",
rhythm: "rhythm",
strength: "power",
stability: "power",
};
const unavailableTrainingIds = new Set(["rhythm", "strength"]);
const unavailableTrainingIds = new Set(["stability"]);
// 训练项目卡片右侧主图标。
const trainingModeIconMap = {
base_bow:
@@ -27,7 +27,7 @@ const trainingModeIconMap = {
"https://static.shelingxingqiu.com/shootmini/static/training-home/img_5.png",
wave: "https://static.shelingxingqiu.com/shootmini/static/training-home/img_6.png",
muscle:
"https://static.shelingxingqiu.com/shootmini/static/training-home/img_6.png",
"https://static.shelingxingqiu.com/shootmini/static/training-home/img_7.png",
};
// 训练项目卡片标题图,按接口 id 映射 CDN 资源。
const trainingModeTitleImageMap = {
@@ -39,21 +39,22 @@ const trainingModeTitleImageMap = {
"https://static.shelingxingqiu.com/shootmini/static/training-home/jingzhunxunlian.png",
rhythm:
"https://static.shelingxingqiu.com/shootmini/static/training-home/jiezouxunlian.png",
strength:
"https://static.shelingxingqiu.com/shootmini/static/training-home/liliangxulian.png",
stability:
"https://static.shelingxingqiu.com/shootmini/static/training-home/wendingxunlian.png",
};
const defaultWeekDays = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
const defaultRadarDimensions = [
{ name: "基础", score: 0 },
{ name: "精准", score: 0 },
{ name: "力量", score: 0 },
{ name: "稳定", score: 0 },
{ name: "节奏", score: 0 },
{ name: "耐力", score: 0 },
];
const radarDimensionTrainingIdMap = Object.freeze({
基础: "base",
精准: "precision",
力量: "strength",
稳定: "stability",
力量: "stability",
节奏: "rhythm",
耐力: "endurance",
});
@@ -66,7 +67,7 @@ const createDefaultTrainingData = () => ({
total_arrows: 0,
target_rate: 0,
ten_ring_count: 0,
total_calories: 0,
average_ring: 0,
},
beat_percent: 0,
radar_max: 0,
@@ -124,20 +125,20 @@ 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);
};
const getAverageRingValue = (value) => formatValue(value);
const getTrainingIcon = (item = {}) =>
trainingModeIconMap[item.icon] || trainingModeIconMap.bow;
@@ -288,7 +289,7 @@ const loadPersonalTrainingData = async () => {
total_arrows: result?.stats?.total_arrows ?? 0,
target_rate: result?.stats?.target_rate ?? 0,
ten_ring_count: result?.stats?.ten_ring_count ?? 0,
total_calories: result?.stats?.total_calories ?? 0,
average_ring: result?.stats?.average_ring ?? 0,
},
beat_percent: result?.beat_percent ?? 0,
radar_max: result?.radar_max ?? 0,
@@ -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.average_ring) }}
</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>
@@ -530,19 +531,26 @@ onShow(async () => {
</view>
</view>
<view class="featured-card" @click="$clickSound(openRoutineTraining)">
<image
class="featured-card-bg"
src="https://static.shelingxingqiu.com/shootmini/static/training-home/img_22.png"
mode="widthFix"
/>
<view class="featured-card-mask"></view>
<view class="featured-card-copy">
<text class="featured-card-subtitle">12箭练习</text>
</view>
</view>
<view class="mode-grid">
<view
class="mode-card"
@click="$clickSound(openRoutineTraining)"
>
<view class="mode-card-copy">
<image
class="mode-card-title-image"
src="https://static.shelingxingqiu.com/shootmini/static/training-home/ziyouxunlian.png"
mode="widthFix"
/>
<text class="mode-card-progress">无级别限制</text>
</view>
<image
class="mode-card-icon"
src="https://static.shelingxingqiu.com/shootmini/static/training-home/img_2.png"
mode="aspectFit"
/>
</view>
<view
v-for="item in visibleTrainingItems"
:key="item.id"
@@ -743,7 +751,7 @@ onShow(async () => {
.radar-section {
position: relative;
padding-top: 34rpx;
padding-top: 30rpx;
}
.record-bubble {
@@ -860,52 +868,6 @@ onShow(async () => {
width: 92rpx;
}
.featured-card {
position: relative;
width: 100%;
height: 150rpx;
margin-top: 70rpx;
border-radius: 16rpx;
overflow: hidden;
}
.featured-card-bg {
width: 100%;
}
.featured-card-mask {
position: absolute;
left: 0;
top: 0;
width: 278rpx;
height: 150rpx;
}
.featured-card-copy {
position: absolute;
left: 174rpx;
top: 58rpx;
display: flex;
flex-direction: column;
}
.featured-card-title {
display: block;
color: #895409;
font-size: 34rpx;
font-family: "AlimamaShuHeiTi-Bold", "PingFang SC", sans-serif;
font-weight: 700;
line-height: 42rpx;
}
.featured-card-subtitle {
display: block;
margin-top: 10rpx;
color: #895409;
font-size: 22rpx;
line-height: 32rpx;
}
.mode-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -915,11 +877,11 @@ onShow(async () => {
.mode-card {
position: relative;
height: 150rpx;
height: 130rpx;
box-shadow: inset 2rpx 2rpx 6rpx 0rpx rgba(255, 255, 255, 0.27);
border-radius: 16rpx;
border: 2rpx solid rgba(235, 184, 123, 0.5);
background: rgba(0, 0, 0, 0.5);
/* background: rgba(0, 0, 0, 0.5); */
overflow: hidden;
}
@@ -928,8 +890,8 @@ onShow(async () => {
left: 0;
top: 0;
width: 72rpx;
height: 34rpx;
line-height: 34rpx;
height: 28rpx;
line-height: 28rpx;
text-align: center;
font-size: 20rpx;
color: #000;
@@ -940,7 +902,7 @@ onShow(async () => {
.mode-card-copy {
position: absolute;
left: 30rpx;
top: 40rpx;
top: 32rpx;
}
.mode-card-title {
@@ -968,16 +930,16 @@ onShow(async () => {
display: block;
margin-top: 14rpx;
color: #fcce96;
font-size: 22rpx;
line-height: 32rpx;
font-size: 20rpx;
line-height: 28rpx;
opacity: 0.5;
}
.mode-card-icon {
position: absolute;
right: 12rpx;
top: 14rpx;
width: 124rpx;
height: 124rpx;
right: 18rpx;
top: 8rpx;
width: 116rpx;
height: 116rpx;
}
</style>
+261 -2
View File
@@ -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,12 @@ 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);
// 节奏训练目标箭数以服务端 total_arrows 为准;首帧缺失时用 arrows_left 初始化并保持稳定。
const rhythmTargetArrows = ref(0);
// 服务端状态立即落到 practiceInfo,精准训练的目标区域单独延迟展示。
const visiblePrecisionTarget = ref({
randomBlock: 0,
@@ -236,6 +264,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 +417,33 @@ const trainingCopy = computed(() => {
};
}
if (trainingType.value === "rhythm") {
const targetArrows = getPositiveInteger(rhythmTargetArrows.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 +477,10 @@ const practiceInfoFields = [
"blocks",
"randomBlock",
"randomRingArea",
"roundTime",
"shootTime",
"shootWindowStart",
"inShootWindow",
"timeLimit",
"completed",
"totalArrows",
@@ -388,6 +496,8 @@ const practiceInfoFields = [
"deltaTotalArrows",
"deltaAverageRing",
"deltaStability",
"tenRingCount",
"deltaTenRingCount",
"beforeExp",
"beforeLevel",
"currentExp",
@@ -416,6 +526,8 @@ const practiceResultFields = [
"deltaTotalArrows",
"deltaAverageRing",
"deltaStability",
"tenRingCount",
"deltaTenRingCount",
"beforeExp",
"beforeLevel",
"currentExp",
@@ -425,7 +537,69 @@ 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 cacheRhythmTargetArrows = (message = {}) => {
const messageTrainingType = String(
message.trainingType ?? message.training_type ?? trainingType.value
).trim();
if (messageTrainingType !== "rhythm") return;
const totalArrows = getPositiveInteger(
message.totalArrows ?? message.total_arrows
);
if (totalArrows > 0) {
rhythmTargetArrows.value = totalArrows;
return;
}
// 兼容服务端首帧 total_arrows 暂为 0:只初始化一次,避免剩余数递减时分母跟着变化。
if (rhythmTargetArrows.value === 0) {
rhythmTargetArrows.value = getPositiveInteger(
message.arrowsLeft ?? message.arrows_left
);
}
};
const syncPracticeInfo = (message = {}) => {
cacheRhythmTrainingConfig(message);
cacheRhythmTargetArrows(message);
const nextInfo = practiceInfoFields.reduce((result, field) => {
if (Object.prototype.hasOwnProperty.call(message, field)) {
result[field] = message[field];
@@ -477,6 +651,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 +893,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 +1220,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 +1236,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 +1268,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 +1339,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 +1398,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 +1452,11 @@ async function onRetry() {
practiseResult.value = {};
practiceEndSnapshot.value = {};
practiceInfo.value = {};
rhythmServerTimestamp.value = 0;
rhythmFallbackWindowStart.value = 0;
rhythmFallbackTimestamp.value = 0;
rhythmHasActiveServerAnchor.value = false;
rhythmTargetArrows.value = 0;
invalidateShotPresentations({ resetVisible: true });
start.value = false;
scores.value = [];
@@ -1332,6 +1579,10 @@ onBeforeUnmount(() => {
:showBottom="isDistanceStage"
:scroll="!isShootingStage"
:onBack="exitPractice"
:title="isShootingStage && isRhythmTraining ? '节奏训练' : ''"
:titleStyle="
isShootingStage && isRhythmTraining ? rhythmHeaderTitleStyle : {}
"
>
<view class="practise-content">
<TestDistance
@@ -1347,9 +1598,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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 B

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

+1 -1
View File
File diff suppressed because one or more lines are too long
+4 -2
View File
@@ -7,8 +7,8 @@ const { Reader, Writer } = protobuf;
// 所以这里使用 minimal Reader/Writer 做静态字段解码和客户端消息编码。
// <match-schema-generated>
// 此区块由 scripts/generate-match-schema.mjs 自动生成,请勿手动修改。
// 来源:src/utils/match.min.js(sha256: a7ab92ae43e405e7)
// 协议命名空间:rpc;消息数:12;字段数:153
// 来源:src/utils/match.min.js(sha256: fbf97fd3d08ae314)
// 协议命名空间:rpc;消息数:12;字段数:155
export const ServerMessageType = {
SERVER_MSG_UNKNOWN: 0,
@@ -196,6 +196,8 @@ const SCHEMAS = {
51: { name: "shoot_window_start", kind: "int64" },
52: { name: "in_shoot_window", kind: "bool" },
53: { name: "shoot_time", kind: "int32" },
54: { name: "ten_ring_count", kind: "int32" },
55: { name: "delta_ten_ring_count", kind: "int32" },
},
MatchInfo: {
1: { name: "match_id", kind: "string" },