diff --git a/scripts/generate-match-schema.mjs b/scripts/generate-match-schema.mjs
index 47d7a76..f012020 100644
--- a/scripts/generate-match-schema.mjs
+++ b/scripts/generate-match-schema.mjs
@@ -264,11 +264,6 @@ function buildSchema({ messageName, definition, messages, enumNames }) {
}
const repeated = rule === "repeated";
- if (repeated && !isMessage && !["string", "bytes"].includes(kind)) {
- throw new Error(
- `${messageName}.${fieldKey} 是 packed scalar repeated,当前通用解码器尚不支持`
- );
- }
const field = isMessage
? { name: fieldName, kind: "message", type: typeName }
diff --git a/src/components/TargetCanvas.vue b/src/components/TargetCanvas.vue
index fd3478a..7026814 100644
--- a/src/components/TargetCanvas.vue
+++ b/src/components/TargetCanvas.vue
@@ -46,10 +46,10 @@ const props = defineProps({
type: Number,
default: 0,
},
- // 指定环数,1 到 10;无效值表示高亮整个区域。
- activeRing: {
- type: Number,
- default: 0,
+ // 指定一个或多个环数,空数组表示高亮整个区域。
+ activeRings: {
+ type: Array,
+ default: () => [],
},
// 每次变化时以固定低帧数重新展开当前高亮扇区;默认关闭。
highlightRefreshToken: {
@@ -192,6 +192,25 @@ const getPositiveInteger = (value) => {
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : 0;
};
+// 过滤、去重并合并连续环,避免 3、4 环同时高亮时绘制中间描边。
+const getActiveRingRanges = (ringCount) => {
+ const rings = [...new Set(
+ (Array.isArray(props.activeRings) ? props.activeRings : [])
+ .map(getPositiveInteger)
+ .filter((ring) => ring >= 1 && ring <= ringCount)
+ )].sort((first, second) => first - second);
+
+ return rings.reduce((ranges, ring) => {
+ const lastRange = ranges[ranges.length - 1];
+ if (lastRange && ring === lastRange.end + 1) {
+ lastRange.end = ring;
+ } else {
+ ranges.push({ start: ring, end: ring });
+ }
+ return ranges;
+ }, []);
+};
+
// 正上方作为第一区起始边界,Canvas 角度递增方向即为顺时针。
const getSectorAngles = (sector, sectorCount) => {
const count = getPositiveInteger(sectorCount);
@@ -256,7 +275,7 @@ const drawTargetRings = (ctx, centerX, centerY, targetRadius, config) => {
}
};
-// 高亮后端指定区域;activeRing 有效时只高亮该区域内的单个环。
+// 高亮后端指定区域;activeRings 非空时高亮该区域内的一个或多个环。
const drawSectorHighlight = (
ctx,
centerX,
@@ -274,32 +293,35 @@ const drawSectorHighlight = (
);
if (safeRevealProgress <= 0) return;
- const ring = getPositiveInteger(props.activeRing);
- const hasActiveRing = ring >= 1 && ring <= config.ringCount;
- const innerRadius = hasActiveRing
- ? targetRadius * ((config.ringCount - ring) / config.ringCount)
- : 0;
- const outerRadius = hasActiveRing
- ? targetRadius * ((config.ringCount + 1 - ring) / config.ringCount)
- : targetRadius;
+ const ringRanges = getActiveRingRanges(config.ringCount);
+ const highlightRanges = ringRanges.length > 0
+ ? ringRanges
+ : [{ start: 1, end: config.ringCount }];
const style = {
...defaultHighlightStyle,
...props.highlightStyle,
};
- drawAnnularSector(
- ctx,
- centerX,
- centerY,
- innerRadius,
- outerRadius,
- angles.startAngle,
- angles.startAngle +
- (angles.endAngle - angles.startAngle) * safeRevealProgress,
- style.color,
- style.strokeColor,
- Math.max(1, targetRadius * style.lineWidthRatio)
- );
+ highlightRanges.forEach((range) => {
+ const innerRadius =
+ targetRadius * ((config.ringCount - range.end) / config.ringCount);
+ const outerRadius =
+ targetRadius * ((config.ringCount + 1 - range.start) / config.ringCount);
+
+ drawAnnularSector(
+ ctx,
+ centerX,
+ centerY,
+ innerRadius,
+ outerRadius,
+ angles.startAngle,
+ angles.startAngle +
+ (angles.endAngle - angles.startAngle) * safeRevealProgress,
+ style.color,
+ style.strokeColor,
+ Math.max(1, targetRadius * style.lineWidthRatio)
+ );
+ });
};
// 从正上方开始顺时针绘制所有区域边界。
@@ -420,7 +442,7 @@ const getDrawKey = (width, height) => {
showRingLabels: props.showRingLabels,
sectorCount: props.sectorCount,
activeSector: props.activeSector,
- activeRing: props.activeRing,
+ activeRings: props.activeRings,
showSectorLabels: props.showSectorLabels,
targetStyleConfig: props.targetStyleConfig,
crosshairStyle: props.crosshairStyle,
@@ -591,7 +613,7 @@ watch(
props.showRingLabels,
props.sectorCount,
props.activeSector,
- props.activeRing,
+ props.activeRings,
props.highlightRefreshToken,
props.showSectorLabels,
props.highlightOnly,
diff --git a/src/pages/training/components/BowTarget.vue b/src/pages/training/components/BowTarget.vue
index 2eaae08..ee3338c 100644
--- a/src/pages/training/components/BowTarget.vue
+++ b/src/pages/training/components/BowTarget.vue
@@ -84,9 +84,9 @@ const props = defineProps({
type: Number,
default: 0,
},
- activeRing: {
- type: Number,
- default: 0,
+ activeRings: {
+ type: Array,
+ default: () => [],
},
showSectorLabels: {
type: Boolean,
@@ -548,7 +548,7 @@ onBeforeUnmount(() => {
:highlightOnly="true"
:sectorCount="sectorCount"
:activeSector="activeSector"
- :activeRing="activeRing"
+ :activeRings="activeRings"
:highlightRefreshToken="highlightRefreshToken"
:showSectorLabels="showSectorLabels"
/>
@@ -877,7 +877,7 @@ onBeforeUnmount(() => {
width: calc(100% - 20px);
padding: 0 10px;
display: flex;
- margin-top: -40px;
+ margin-top: 15px;
justify-content: flex-end;
}
.footer > image {
diff --git a/src/pages/training/components/ShootProgress.vue b/src/pages/training/components/ShootProgress.vue
index a220e1c..7b4bf1d 100644
--- a/src/pages/training/components/ShootProgress.vue
+++ b/src/pages/training/components/ShootProgress.vue
@@ -73,11 +73,7 @@ const props = defineProps({
type: Number,
default: 0,
},
- energyPercent: {
- type: Number,
- default: 0,
- },
- energyReqPercent: {
+ energyCostPerSec: {
type: Number,
default: 0,
},
@@ -187,17 +183,10 @@ const progressPercent = computed(() => {
return Math.max(0, Math.min(100, (remain.value / props.total) * 100));
});
-const stabilityEnergyPercent = computed(() =>
- Math.max(0, Math.min(100, Number(props.energyPercent) || 0))
-);
-const stabilityEnergyReqPercent = computed(() =>
- Math.max(0, Math.min(100, Number(props.energyReqPercent) || 0))
-);
-const stabilityEnergyReached = computed(
- () =>
- stabilityEnergyReqPercent.value > 0 &&
- stabilityEnergyPercent.value >= stabilityEnergyReqPercent.value
-);
+const stabilityEnergyCostText = computed(() => {
+ const value = Math.max(0, Number(props.energyCostPerSec) || 0);
+ return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2)));
+});
const rhythmMarkerPercent = computed(() => {
if (!validRhythmRoundTime.value) return 0;
@@ -568,22 +557,18 @@ onBeforeUnmount(() => {
"
>
- {{ remain }}秒
-
+
+ 每秒失去{{ stabilityEnergyCostText }}点能量
+
+
-
-
- {{ Math.round(stabilityEnergyPercent) }}%
-
+ 剩余{{ remain }}秒
@@ -811,17 +796,17 @@ onBeforeUnmount(() => {
margin: 32rpx 84rpx 0;
}
-.stability-progress__time {
+.stability-progress__cost {
display: block;
margin-bottom: 20rpx;
color: #ffffff;
- font-size: 34rpx;
+ font-size: 32rpx;
font-weight: 500;
- line-height: 48rpx;
+ line-height: 44rpx;
text-align: center;
}
-.stability-progress__track {
+.stability-progress__countdown {
position: relative;
width: 100%;
height: 24rpx;
@@ -830,38 +815,23 @@ onBeforeUnmount(() => {
background: #444444;
}
-.stability-progress__fill {
+.stability-progress__countdown-fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
border-radius: 18rpx;
- background: linear-gradient(90deg, #87f1df 0%, #5ba8e8 100%);
- transition: width 240ms linear, background 240ms ease;
+ background: linear-gradient(133deg, #ffd19a 0%, #a17636 100%);
}
-.stability-progress__fill--reached {
- background: linear-gradient(90deg, #a5df62 0%, #61c787 100%);
-}
-
-.stability-progress__marker {
- position: absolute;
- top: 0;
- bottom: 0;
- z-index: 2;
- width: 4rpx;
- transform: translateX(-2rpx);
- background: rgba(26, 24, 22, 0.92);
-}
-
-.stability-progress__value {
+.stability-progress__countdown-text {
position: absolute;
inset: 0;
- z-index: 3;
+ z-index: 2;
display: flex;
align-items: center;
justify-content: center;
- color: #eefaff;
+ color: #fff7de;
font-size: 18rpx;
line-height: 24rpx;
text-align: center;
diff --git a/src/pages/training/components/StabilityEnergyTrack.vue b/src/pages/training/components/StabilityEnergyTrack.vue
new file mode 100644
index 0000000..15f6c9a
--- /dev/null
+++ b/src/pages/training/components/StabilityEnergyTrack.vue
@@ -0,0 +1,730 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/training/practise-one.vue b/src/pages/training/practise-one.vue
index c495dc1..06ec20d 100644
--- a/src/pages/training/practise-one.vue
+++ b/src/pages/training/practise-one.vue
@@ -3,6 +3,7 @@ import { computed, ref, onMounted, onBeforeUnmount } from "vue";
import { onHide, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
import Container from "@/components/Container.vue";
import ShootProgress from "./components/ShootProgress.vue";
+import StabilityEnergyTrack from "./components/StabilityEnergyTrack.vue";
import BowTarget from "./components/BowTarget.vue";
import ScorePanel2 from "@/components/TrainingScorePanel.vue";
import ScoreResult from "./components/ScoreResult.vue";
@@ -48,6 +49,7 @@ const { user } = storeToRefs(store);
const sound = ref(true);
const start = ref(false);
const practiceStarting = ref(false);
+const stabilityEnergyIntroPending = ref(false);
const pageStages = Object.freeze({
DISTANCE: "distance",
SHOOTING: "shooting",
@@ -105,14 +107,14 @@ const rhythmTargetArrows = ref(0);
// 服务端状态立即落到 practiceInfo,精准训练的目标区域单独延迟展示。
const visiblePrecisionTarget = ref({
randomBlock: 0,
- randomRingArea: 0,
+ randomRingAreas: [],
});
const trainingDifficultyStorageKey = "training-selection";
const useHighlightTest = ref(false);
const highlightTestState = ref({
blocks: 8,
randomBlock: 1,
- randomRingArea: 0,
+ randomRingAreas: [],
});
const highlightTestTimer = ref(null);
const serverAddr = ref("");
@@ -133,11 +135,14 @@ let shotPresentationGeneration = 0;
let nextDifficultyRequest = null;
let nextDifficultyRequestGeneration = 0;
const audioWaiters = new Set();
+let stabilityEnergyIntroGeneration = 0;
+let stabilityEnergyIntroTimer = null;
const shotEffectWaiters = new Map();
const PRACTICE_SYNC_TIMEOUT_MS = 5000;
const SHOT_EFFECT_WAIT_TIMEOUT_MS = 1200;
const AUDIO_TIMEOUT_BASE = 3500;
const AUDIO_TIMEOUT_PER_KEY = 2600;
+const STABILITY_ENERGY_ENTRY_DELAY_MS = 1000;
const AUDIO_TIMEOUT_MAX = 12000;
const RUNTIME_DIAGNOSTIC_INTERVAL_MS = 60000;
let lastRuntimeDiagnosticAt = 0;
@@ -195,9 +200,23 @@ const getPositiveInteger = (value) => {
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : 0;
};
+const normalizePrecisionRingAreas = (source = {}) => {
+ const multipleRings = Array.isArray(source.randomRingAreas)
+ ? [...new Set(source.randomRingAreas.map(getPositiveInteger))].filter(
+ (ring) => ring >= 1 && ring <= 10
+ )
+ : [];
+
+ if (multipleRings.length > 0) return multipleRings;
+
+ const singleRing = getPositiveInteger(source.randomRingArea);
+ return singleRing >= 1 && singleRing <= 10 ? [singleRing] : [];
+};
+
const getPrecisionTargetSnapshot = (source = {}) => ({
randomBlock: getPositiveInteger(source.randomBlock),
- randomRingArea: getPositiveInteger(source.randomRingArea),
+ // 多环字段优先;后端仅在单环时继续下发旧单值字段。
+ randomRingAreas: normalizePrecisionRingAreas(source),
});
const applyVisiblePrecisionTarget = (source = {}) => {
@@ -345,6 +364,15 @@ const stabilityEnergyPercent = computed(() => {
(stabilityCurrentEnergy.value / stabilityScoreSlot.value) * 100
);
});
+const stabilityEnergyCostPerSec = computed(() =>
+ Math.max(
+ 0,
+ getPracticeNumber(
+ practiceInfo.value.energyCostPerSec,
+ trainingParams.value.energyCostPerSec
+ )
+ )
+);
const initializeRhythmFirstRoundCountdown = () => {
rhythmHasActiveServerAnchor.value = false;
@@ -384,14 +412,13 @@ const precisionRandomBlock = computed(() => {
return block <= precisionBlocks.value ? block : 0;
});
-const precisionRandomRingArea = computed(() => {
- const ring = getPositiveInteger(
+const precisionRandomRingAreas = computed(() =>
+ normalizePrecisionRingAreas(
useHighlightTest.value
- ? highlightTestState.value.randomRingArea
- : visiblePrecisionTarget.value.randomRingArea
- );
- return ring >= 1 && ring <= 10 ? ring : 0;
-});
+ ? highlightTestState.value
+ : visiblePrecisionTarget.value
+ )
+);
// 只展示后端进度,不在前端重复判断训练是否完成。
const trainingCopy = computed(() => {
@@ -555,6 +582,7 @@ const practiceInfoFields = [
"blocks",
"randomBlock",
"randomRingArea",
+ "randomRingAreas",
"roundTime",
"shootTime",
"shootWindowStart",
@@ -746,6 +774,9 @@ const syncPracticeInfo = (message = {}) => {
if (!Object.prototype.hasOwnProperty.call(message, "randomRingArea")) {
nextInfo.randomRingArea = 0;
}
+ if (!Object.prototype.hasOwnProperty.call(message, "randomRingAreas")) {
+ nextInfo.randomRingAreas = [];
+ }
}
if (Object.keys(nextInfo).length === 0) return;
@@ -836,6 +867,12 @@ const playAudioKeysAndWait = (keys) => {
});
};
+const clearStabilityEnergyIntroTimer = () => {
+ if (!stabilityEnergyIntroTimer) return;
+ clearTimeout(stabilityEnergyIntroTimer);
+ stabilityEnergyIntroTimer = null;
+};
+
const shouldWaitForShotEffect = (shot) => {
const x = Number(shot?.x);
const y = Number(shot?.y);
@@ -1290,7 +1327,7 @@ const clearHighlightTestTimer = () => {
}
};
-// 开发环境测试入口:依次切换 8 个顺时针区域,偶数区域只高亮指定环。
+// 开发环境测试入口:依次切换 8 个顺时针区域,偶数区域同时高亮两个环。
const runHighlightTest = () => {
clearHighlightTestTimer();
useHighlightTest.value = true;
@@ -1302,7 +1339,7 @@ const runHighlightTest = () => {
highlightTestState.value = {
blocks: 8,
randomBlock: block,
- randomRingArea: 0,
+ randomRingAreas: [],
};
highlightTestTimer.value = setInterval(() => {
@@ -1315,7 +1352,10 @@ const runHighlightTest = () => {
highlightTestState.value = {
blocks: 8,
randomBlock: block,
- randomRingArea: block % 2 === 0 ? Math.min(block, 10) : 0,
+ randomRingAreas:
+ block % 2 === 0
+ ? [Math.min(block, 9), Math.min(block + 1, 10)]
+ : [],
};
}, 1000);
};
@@ -1326,7 +1366,7 @@ const resetHighlightTest = () => {
highlightTestState.value = {
blocks: 8,
randomBlock: 1,
- randomRingArea: 0,
+ randomRingAreas: [],
};
scores.value = [];
};
@@ -1375,6 +1415,9 @@ onLoad((options = {}) => {
toRouteNumber(trainingContext.scoreSlot)
),
};
+ // 稳定训练在测距阶段预挂载 Canvas,并提前绘制 100% 满能量首帧。
+ stabilityEnergyIntroPending.value =
+ trainingParams.value.type === "stability";
practiseId.value = trainingContext.practiceId || "";
serverAddr.value = trainingContext.serverAddr || "";
@@ -1420,15 +1463,30 @@ const onReady = async () => {
scores.value = [];
shotEffectToken.value = 0;
initializeRhythmFirstRoundCountdown();
+ const shouldPlayStabilityEnergyIntro =
+ trainingType.value === "stability";
+ clearStabilityEnergyIntroTimer();
+ const stabilityIntroGeneration = ++stabilityEnergyIntroGeneration;
+ stabilityEnergyIntroPending.value = shouldPlayStabilityEnergyIntro;
+ const startAudioKey = getTrainingStartAudioKey(
+ trainingType.value,
+ "练习开始"
+ );
start.value = true;
pageStage.value = pageStages.SHOOTING;
setPracticeAppHideResumable(true);
// 先由本地锚点保证首轮立即显示,再用最新服务端快照无感校准。
requestPracticeInfoSync();
- // 开始接口成功即进入正式训练,直接播放对应提示,避免依赖 BattleStart 消息。
- audioManager.play(
- getTrainingStartAudioKey(trainingType.value, "练习开始")
- );
+ // 开始语音只进入原播放队列,不参与能量条的入场计时。
+ audioManager.play(startAudioKey);
+ if (shouldPlayStabilityEnergyIntro) {
+ stabilityEnergyIntroTimer = setTimeout(() => {
+ stabilityEnergyIntroTimer = null;
+ if (stabilityIntroGeneration !== stabilityEnergyIntroGeneration) return;
+ if (!isShootingStage.value || !isStabilityTraining.value) return;
+ stabilityEnergyIntroPending.value = false;
+ }, STABILITY_ENERGY_ENTRY_DELAY_MS);
+ }
} catch (error) {
start.value = false;
pageStage.value = pageStages.DISTANCE;
@@ -1619,6 +1677,9 @@ function onComplete() {
async function onRetry() {
pageStage.value = pageStages.LOADING;
+ clearStabilityEnergyIntroTimer();
+ stabilityEnergyIntroGeneration += 1;
+ stabilityEnergyIntroPending.value = isStabilityTraining.value;
setPracticeAppHideResumable(false);
clearHighlightTestTimer();
useHighlightTest.value = false;
@@ -1731,6 +1792,9 @@ onMounted(() => {
});
onBeforeUnmount(() => {
+ clearStabilityEnergyIntroTimer();
+ stabilityEnergyIntroGeneration += 1;
+ stabilityEnergyIntroPending.value = false;
setPracticeAppHideResumable(false);
invalidateShotPresentations();
clearPracticeRuntimeContext();
@@ -1790,8 +1854,7 @@ onBeforeUnmount(() => {
:inShootWindow="rhythmInShootWindow"
:serverTimestamp="rhythmCountdownTimestamp"
:hitReq="rhythmHitReq"
- :energyPercent="stabilityEnergyPercent"
- :energyReqPercent="stabilityEnergyReqPercent"
+ :energyCostPerSec="stabilityEnergyCostPerSec"
:isVip="isVip"
:isSvip="isSvip"
:externalShootResultAudio="
@@ -1819,11 +1882,16 @@ onBeforeUnmount(() => {
:showCrosshair="false"
:sectorCount="precisionBlocks"
:activeSector="precisionRandomBlock"
- :activeRing="precisionRandomRingArea"
+ :activeRings="precisionRandomRingAreas"
:highlightRefreshToken="precisionTargetRefreshToken"
stable-shot-effect
@shot-effect-complete="onShotEffectComplete"
/>
+