Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a06dcf942 | ||
|
|
35e0ee3ca0 | ||
|
|
59fa317c5a |
@@ -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,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(() => {
|
||||
@@ -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;
|
||||
|
||||
@@ -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,11 +528,40 @@ 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
|
||||
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">
|
||||
@@ -328,6 +618,7 @@ onBeforeUnmount(() => {
|
||||
</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);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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)">
|
||||
<view class="mode-grid">
|
||||
<view
|
||||
class="mode-card"
|
||||
@click="$clickSound(openRoutineTraining)"
|
||||
>
|
||||
<view class="mode-card-copy">
|
||||
<image
|
||||
class="featured-card-bg"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/training-home/img_22.png"
|
||||
class="mode-card-title-image"
|
||||
src="https://static.shelingxingqiu.com/shootmini/static/training-home/ziyouxunlian.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view class="featured-card-mask"></view>
|
||||
<view class="featured-card-copy">
|
||||
<text class="featured-card-subtitle">12箭练习</text>
|
||||
<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 class="mode-grid">
|
||||
<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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 173 B After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
@@ -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" },
|
||||
|
||||