update:优化精准稳定
This commit is contained in:
@@ -264,11 +264,6 @@ function buildSchema({ messageName, definition, messages, enumNames }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const repeated = rule === "repeated";
|
const repeated = rule === "repeated";
|
||||||
if (repeated && !isMessage && !["string", "bytes"].includes(kind)) {
|
|
||||||
throw new Error(
|
|
||||||
`${messageName}.${fieldKey} 是 packed scalar repeated,当前通用解码器尚不支持`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const field = isMessage
|
const field = isMessage
|
||||||
? { name: fieldName, kind: "message", type: typeName }
|
? { name: fieldName, kind: "message", type: typeName }
|
||||||
|
|||||||
@@ -46,10 +46,10 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 0,
|
default: 0,
|
||||||
},
|
},
|
||||||
// 指定环数,1 到 10;无效值表示高亮整个区域。
|
// 指定一个或多个环数,空数组表示高亮整个区域。
|
||||||
activeRing: {
|
activeRings: {
|
||||||
type: Number,
|
type: Array,
|
||||||
default: 0,
|
default: () => [],
|
||||||
},
|
},
|
||||||
// 每次变化时以固定低帧数重新展开当前高亮扇区;默认关闭。
|
// 每次变化时以固定低帧数重新展开当前高亮扇区;默认关闭。
|
||||||
highlightRefreshToken: {
|
highlightRefreshToken: {
|
||||||
@@ -192,6 +192,25 @@ const getPositiveInteger = (value) => {
|
|||||||
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : 0;
|
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 角度递增方向即为顺时针。
|
// 正上方作为第一区起始边界,Canvas 角度递增方向即为顺时针。
|
||||||
const getSectorAngles = (sector, sectorCount) => {
|
const getSectorAngles = (sector, sectorCount) => {
|
||||||
const count = getPositiveInteger(sectorCount);
|
const count = getPositiveInteger(sectorCount);
|
||||||
@@ -256,7 +275,7 @@ const drawTargetRings = (ctx, centerX, centerY, targetRadius, config) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 高亮后端指定区域;activeRing 有效时只高亮该区域内的单个环。
|
// 高亮后端指定区域;activeRings 非空时高亮该区域内的一个或多个环。
|
||||||
const drawSectorHighlight = (
|
const drawSectorHighlight = (
|
||||||
ctx,
|
ctx,
|
||||||
centerX,
|
centerX,
|
||||||
@@ -274,19 +293,21 @@ const drawSectorHighlight = (
|
|||||||
);
|
);
|
||||||
if (safeRevealProgress <= 0) return;
|
if (safeRevealProgress <= 0) return;
|
||||||
|
|
||||||
const ring = getPositiveInteger(props.activeRing);
|
const ringRanges = getActiveRingRanges(config.ringCount);
|
||||||
const hasActiveRing = ring >= 1 && ring <= config.ringCount;
|
const highlightRanges = ringRanges.length > 0
|
||||||
const innerRadius = hasActiveRing
|
? ringRanges
|
||||||
? targetRadius * ((config.ringCount - ring) / config.ringCount)
|
: [{ start: 1, end: config.ringCount }];
|
||||||
: 0;
|
|
||||||
const outerRadius = hasActiveRing
|
|
||||||
? targetRadius * ((config.ringCount + 1 - ring) / config.ringCount)
|
|
||||||
: targetRadius;
|
|
||||||
const style = {
|
const style = {
|
||||||
...defaultHighlightStyle,
|
...defaultHighlightStyle,
|
||||||
...props.highlightStyle,
|
...props.highlightStyle,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
highlightRanges.forEach((range) => {
|
||||||
|
const innerRadius =
|
||||||
|
targetRadius * ((config.ringCount - range.end) / config.ringCount);
|
||||||
|
const outerRadius =
|
||||||
|
targetRadius * ((config.ringCount + 1 - range.start) / config.ringCount);
|
||||||
|
|
||||||
drawAnnularSector(
|
drawAnnularSector(
|
||||||
ctx,
|
ctx,
|
||||||
centerX,
|
centerX,
|
||||||
@@ -300,6 +321,7 @@ const drawSectorHighlight = (
|
|||||||
style.strokeColor,
|
style.strokeColor,
|
||||||
Math.max(1, targetRadius * style.lineWidthRatio)
|
Math.max(1, targetRadius * style.lineWidthRatio)
|
||||||
);
|
);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// 从正上方开始顺时针绘制所有区域边界。
|
// 从正上方开始顺时针绘制所有区域边界。
|
||||||
@@ -420,7 +442,7 @@ const getDrawKey = (width, height) => {
|
|||||||
showRingLabels: props.showRingLabels,
|
showRingLabels: props.showRingLabels,
|
||||||
sectorCount: props.sectorCount,
|
sectorCount: props.sectorCount,
|
||||||
activeSector: props.activeSector,
|
activeSector: props.activeSector,
|
||||||
activeRing: props.activeRing,
|
activeRings: props.activeRings,
|
||||||
showSectorLabels: props.showSectorLabels,
|
showSectorLabels: props.showSectorLabels,
|
||||||
targetStyleConfig: props.targetStyleConfig,
|
targetStyleConfig: props.targetStyleConfig,
|
||||||
crosshairStyle: props.crosshairStyle,
|
crosshairStyle: props.crosshairStyle,
|
||||||
@@ -591,7 +613,7 @@ watch(
|
|||||||
props.showRingLabels,
|
props.showRingLabels,
|
||||||
props.sectorCount,
|
props.sectorCount,
|
||||||
props.activeSector,
|
props.activeSector,
|
||||||
props.activeRing,
|
props.activeRings,
|
||||||
props.highlightRefreshToken,
|
props.highlightRefreshToken,
|
||||||
props.showSectorLabels,
|
props.showSectorLabels,
|
||||||
props.highlightOnly,
|
props.highlightOnly,
|
||||||
|
|||||||
@@ -84,9 +84,9 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 0,
|
default: 0,
|
||||||
},
|
},
|
||||||
activeRing: {
|
activeRings: {
|
||||||
type: Number,
|
type: Array,
|
||||||
default: 0,
|
default: () => [],
|
||||||
},
|
},
|
||||||
showSectorLabels: {
|
showSectorLabels: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -548,7 +548,7 @@ onBeforeUnmount(() => {
|
|||||||
:highlightOnly="true"
|
:highlightOnly="true"
|
||||||
:sectorCount="sectorCount"
|
:sectorCount="sectorCount"
|
||||||
:activeSector="activeSector"
|
:activeSector="activeSector"
|
||||||
:activeRing="activeRing"
|
:activeRings="activeRings"
|
||||||
:highlightRefreshToken="highlightRefreshToken"
|
:highlightRefreshToken="highlightRefreshToken"
|
||||||
:showSectorLabels="showSectorLabels"
|
:showSectorLabels="showSectorLabels"
|
||||||
/>
|
/>
|
||||||
@@ -877,7 +877,7 @@ onBeforeUnmount(() => {
|
|||||||
width: calc(100% - 20px);
|
width: calc(100% - 20px);
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
margin-top: -40px;
|
margin-top: 15px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
.footer > image {
|
.footer > image {
|
||||||
|
|||||||
@@ -73,11 +73,7 @@ const props = defineProps({
|
|||||||
type: Number,
|
type: Number,
|
||||||
default: 0,
|
default: 0,
|
||||||
},
|
},
|
||||||
energyPercent: {
|
energyCostPerSec: {
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
energyReqPercent: {
|
|
||||||
type: Number,
|
type: Number,
|
||||||
default: 0,
|
default: 0,
|
||||||
},
|
},
|
||||||
@@ -187,17 +183,10 @@ const progressPercent = computed(() => {
|
|||||||
return Math.max(0, Math.min(100, (remain.value / props.total) * 100));
|
return Math.max(0, Math.min(100, (remain.value / props.total) * 100));
|
||||||
});
|
});
|
||||||
|
|
||||||
const stabilityEnergyPercent = computed(() =>
|
const stabilityEnergyCostText = computed(() => {
|
||||||
Math.max(0, Math.min(100, Number(props.energyPercent) || 0))
|
const value = Math.max(0, Number(props.energyCostPerSec) || 0);
|
||||||
);
|
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2)));
|
||||||
const stabilityEnergyReqPercent = computed(() =>
|
});
|
||||||
Math.max(0, Math.min(100, Number(props.energyReqPercent) || 0))
|
|
||||||
);
|
|
||||||
const stabilityEnergyReached = computed(
|
|
||||||
() =>
|
|
||||||
stabilityEnergyReqPercent.value > 0 &&
|
|
||||||
stabilityEnergyPercent.value >= stabilityEnergyReqPercent.value
|
|
||||||
);
|
|
||||||
|
|
||||||
const rhythmMarkerPercent = computed(() => {
|
const rhythmMarkerPercent = computed(() => {
|
||||||
if (!validRhythmRoundTime.value) return 0;
|
if (!validRhythmRoundTime.value) return 0;
|
||||||
@@ -568,22 +557,18 @@ onBeforeUnmount(() => {
|
|||||||
"
|
"
|
||||||
>
|
>
|
||||||
<template v-if="isStabilityTraining">
|
<template v-if="isStabilityTraining">
|
||||||
<text class="stability-progress__time">{{ remain }}秒</text>
|
<text class="stability-progress__cost">
|
||||||
<view class="stability-progress__track">
|
每秒失去{{ stabilityEnergyCostText }}点能量
|
||||||
<view
|
|
||||||
class="stability-progress__fill"
|
|
||||||
:class="{
|
|
||||||
'stability-progress__fill--reached': stabilityEnergyReached,
|
|
||||||
}"
|
|
||||||
:style="{ width: `${stabilityEnergyPercent}%` }"
|
|
||||||
/>
|
|
||||||
<view
|
|
||||||
class="stability-progress__marker"
|
|
||||||
:style="{ left: `${stabilityEnergyReqPercent}%` }"
|
|
||||||
/>
|
|
||||||
<text class="stability-progress__value">
|
|
||||||
{{ Math.round(stabilityEnergyPercent) }}%
|
|
||||||
</text>
|
</text>
|
||||||
|
<view v-if="countdownEnabled" class="stability-progress__countdown">
|
||||||
|
<view
|
||||||
|
class="stability-progress__countdown-fill"
|
||||||
|
:style="{
|
||||||
|
width: `${progressPercent}%`,
|
||||||
|
transition: transitionStyle,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<text class="stability-progress__countdown-text">剩余{{ remain }}秒</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="isRhythmTraining">
|
<template v-else-if="isRhythmTraining">
|
||||||
@@ -811,17 +796,17 @@ onBeforeUnmount(() => {
|
|||||||
margin: 32rpx 84rpx 0;
|
margin: 32rpx 84rpx 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stability-progress__time {
|
.stability-progress__cost {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 20rpx;
|
margin-bottom: 20rpx;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-size: 34rpx;
|
font-size: 32rpx;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 48rpx;
|
line-height: 44rpx;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stability-progress__track {
|
.stability-progress__countdown {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 24rpx;
|
height: 24rpx;
|
||||||
@@ -830,38 +815,23 @@ onBeforeUnmount(() => {
|
|||||||
background: #444444;
|
background: #444444;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stability-progress__fill {
|
.stability-progress__countdown-fill {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
border-radius: 18rpx;
|
border-radius: 18rpx;
|
||||||
background: linear-gradient(90deg, #87f1df 0%, #5ba8e8 100%);
|
background: linear-gradient(133deg, #ffd19a 0%, #a17636 100%);
|
||||||
transition: width 240ms linear, background 240ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stability-progress__fill--reached {
|
.stability-progress__countdown-text {
|
||||||
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 {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 3;
|
z-index: 2;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #eefaff;
|
color: #fff7de;
|
||||||
font-size: 18rpx;
|
font-size: 18rpx;
|
||||||
line-height: 24rpx;
|
line-height: 24rpx;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -0,0 +1,626 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
getCurrentInstance,
|
||||||
|
nextTick,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
} from "vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
percent: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const WIDTH = 750;
|
||||||
|
const HEIGHT = 228;
|
||||||
|
const MASK_SCALE = 3;
|
||||||
|
const MASK_WIDTH = WIDTH / MASK_SCALE;
|
||||||
|
const MASK_HEIGHT = HEIGHT / MASK_SCALE;
|
||||||
|
const ENTRY_PADDING = 24;
|
||||||
|
const EXIT_PADDING = 32;
|
||||||
|
const MAX_FEATHER = 60;
|
||||||
|
const FULL_TRACK_OFFSET_Y = -3;
|
||||||
|
const SWEEP_TRACK_RADIUS = 28;
|
||||||
|
const SWEEP_TRACK_FEATHER = 8;
|
||||||
|
const ENTRY_ANIMATION_DURATION = 1000;
|
||||||
|
const UPDATE_DURATION = 240;
|
||||||
|
const SWEEP_DELAY = 100;
|
||||||
|
const SWEEP_DURATION = 1000;
|
||||||
|
const ASSET_ROOT = "https://static.shelingxingqiu.com/shootmini/static/training-difficulty-design/stability-energy";
|
||||||
|
|
||||||
|
const instance = getCurrentInstance();
|
||||||
|
const canvasId = `stability-energy-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
const canvasReady = ref(false);
|
||||||
|
const normalizedPercent = computed(() =>
|
||||||
|
Math.max(0, Math.min(100, Number(props.percent) || 0))
|
||||||
|
);
|
||||||
|
const shouldPlayEntryAnimation = ref(normalizedPercent.value <= 0);
|
||||||
|
const fallbackImageSource = computed(
|
||||||
|
() =>
|
||||||
|
`${ASSET_ROOT}/${
|
||||||
|
shouldPlayEntryAnimation.value ? "progress-full.png" : "progress-empty.png"
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
|
||||||
|
let engine = null;
|
||||||
|
let disposed = false;
|
||||||
|
let animationHandle = null;
|
||||||
|
let renderedPercent = normalizedPercent.value;
|
||||||
|
let entryAnimationPlaying = false;
|
||||||
|
|
||||||
|
const requestFrame = (callback) => {
|
||||||
|
if (typeof requestAnimationFrame === "function") {
|
||||||
|
return { type: "frame", id: requestAnimationFrame(callback) };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "timer",
|
||||||
|
id: setTimeout(() => callback(Date.now()), 16),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelFrame = () => {
|
||||||
|
if (!animationHandle) return;
|
||||||
|
if (
|
||||||
|
animationHandle.type === "frame" &&
|
||||||
|
typeof cancelAnimationFrame === "function"
|
||||||
|
) {
|
||||||
|
cancelAnimationFrame(animationHandle.id);
|
||||||
|
} else {
|
||||||
|
clearTimeout(animationHandle.id);
|
||||||
|
}
|
||||||
|
animationHandle = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const smoothStep = (value) => {
|
||||||
|
const amount = Math.max(0, Math.min(1, value));
|
||||||
|
return amount * amount * (3 - 2 * amount);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 复用参考 HTML 的贝塞尔路径,保证能量光带沿靶纸下方的弧线推进。
|
||||||
|
const sampleTrack = () => {
|
||||||
|
const curves = [
|
||||||
|
[280, 26, 226, 31, 121, 49, 125, 82],
|
||||||
|
[125, 82, 128, 128, 272, 163, 412, 163],
|
||||||
|
[412, 163, 530, 164, 650, 137, 657, 98],
|
||||||
|
[657, 98, 659, 86, 650, 76, 643, 72],
|
||||||
|
];
|
||||||
|
const points = [{ x: 280, y: 26, distance: 0 }];
|
||||||
|
|
||||||
|
curves.forEach((curve) => {
|
||||||
|
for (let step = 1; step <= 32; step += 1) {
|
||||||
|
const amount = step / 32;
|
||||||
|
const rest = 1 - amount;
|
||||||
|
const point = {
|
||||||
|
x:
|
||||||
|
rest ** 3 * curve[0] +
|
||||||
|
3 * rest ** 2 * amount * curve[2] +
|
||||||
|
3 * rest * amount ** 2 * curve[4] +
|
||||||
|
amount ** 3 * curve[6],
|
||||||
|
y:
|
||||||
|
rest ** 3 * curve[1] +
|
||||||
|
3 * rest ** 2 * amount * curve[3] +
|
||||||
|
3 * rest * amount ** 2 * curve[5] +
|
||||||
|
amount ** 3 * curve[7],
|
||||||
|
};
|
||||||
|
const previous = points[points.length - 1];
|
||||||
|
point.distance =
|
||||||
|
previous.distance + Math.hypot(point.x - previous.x, point.y - previous.y);
|
||||||
|
points.push(point);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return points;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createOffscreenCanvas = (canvas, width = WIDTH, height = HEIGHT) => {
|
||||||
|
let layerCanvas = null;
|
||||||
|
|
||||||
|
if (typeof canvas.createOffscreenCanvas === "function") {
|
||||||
|
try {
|
||||||
|
layerCanvas = canvas.createOffscreenCanvas({ type: "2d", width, height });
|
||||||
|
} catch (error) {
|
||||||
|
layerCanvas = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!layerCanvas &&
|
||||||
|
typeof wx !== "undefined" &&
|
||||||
|
typeof wx.createOffscreenCanvas === "function"
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
layerCanvas = wx.createOffscreenCanvas({ type: "2d", width, height });
|
||||||
|
} catch (error) {
|
||||||
|
layerCanvas = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!layerCanvas && typeof document !== "undefined") {
|
||||||
|
layerCanvas = document.createElement("canvas");
|
||||||
|
layerCanvas.width = width;
|
||||||
|
layerCanvas.height = height;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!layerCanvas) throw new Error("Offscreen canvas unavailable");
|
||||||
|
layerCanvas.width = width;
|
||||||
|
layerCanvas.height = height;
|
||||||
|
const context = layerCanvas.getContext("2d");
|
||||||
|
if (!context) throw new Error("Offscreen canvas context unavailable");
|
||||||
|
return { canvas: layerCanvas, context };
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadCanvasImage = (canvas, source) =>
|
||||||
|
new Promise((resolve, reject) => {
|
||||||
|
const image =
|
||||||
|
typeof canvas.createImage === "function"
|
||||||
|
? canvas.createImage()
|
||||||
|
: typeof Image !== "undefined"
|
||||||
|
? new Image()
|
||||||
|
: null;
|
||||||
|
if (!image) {
|
||||||
|
reject(new Error("Canvas image unavailable"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
image.onload = () => resolve(image);
|
||||||
|
image.onerror = () => reject(new Error(`Image unavailable: ${source}`));
|
||||||
|
image.src = source;
|
||||||
|
});
|
||||||
|
|
||||||
|
const createEnergyEngine = async (canvas) => {
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) throw new Error("Canvas unavailable");
|
||||||
|
|
||||||
|
canvas.width = WIDTH;
|
||||||
|
canvas.height = HEIGHT;
|
||||||
|
|
||||||
|
const maskLayer = createOffscreenCanvas(canvas, MASK_WIDTH, MASK_HEIGHT);
|
||||||
|
const emptyLayer = createOffscreenCanvas(canvas);
|
||||||
|
const fullLayer = createOffscreenCanvas(canvas);
|
||||||
|
const glowLayer = createOffscreenCanvas(canvas);
|
||||||
|
const sweepLayer = createOffscreenCanvas(canvas);
|
||||||
|
const track = sampleTrack();
|
||||||
|
const pathLength = track[track.length - 1].distance;
|
||||||
|
const revealStarts = new Float32Array(MASK_WIDTH * MASK_HEIGHT);
|
||||||
|
const revealRates = new Float32Array(MASK_WIDTH * MASK_HEIGHT);
|
||||||
|
const sweepTrackAlpha = new Float32Array(MASK_WIDTH * MASK_HEIGHT);
|
||||||
|
const maskPixels = maskLayer.context.createImageData(MASK_WIDTH, MASK_HEIGHT);
|
||||||
|
maskPixels.data.fill(255);
|
||||||
|
|
||||||
|
const images = await Promise.all([
|
||||||
|
loadCanvasImage(canvas, `${ASSET_ROOT}/progress-empty.png`),
|
||||||
|
loadCanvasImage(canvas, `${ASSET_ROOT}/progress-full.png`),
|
||||||
|
loadCanvasImage(canvas, `${ASSET_ROOT}/progress-glow.png`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const segments = track.slice(1).map((end, index) => {
|
||||||
|
const start = track[index];
|
||||||
|
const deltaX = end.x - start.x;
|
||||||
|
const deltaY = end.y - start.y;
|
||||||
|
return {
|
||||||
|
start,
|
||||||
|
deltaX,
|
||||||
|
deltaY,
|
||||||
|
length: end.distance - start.distance,
|
||||||
|
inverseSquaredLength: 1 / (deltaX ** 2 + deltaY ** 2),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 初始化每个蒙版像素抵达能量路径的时刻,后续更新只重绘,不重复计算。
|
||||||
|
for (let pixel = 0; pixel < revealStarts.length; pixel += 1) {
|
||||||
|
const pixelX = ((pixel % MASK_WIDTH) + 0.5) * MASK_SCALE;
|
||||||
|
const pixelY = (Math.floor(pixel / MASK_WIDTH) + 0.5) * MASK_SCALE;
|
||||||
|
let nearestSquaredDistance = Infinity;
|
||||||
|
let arrivalDistance = 0;
|
||||||
|
|
||||||
|
for (let index = 0; index < segments.length; index += 1) {
|
||||||
|
const segment = segments[index];
|
||||||
|
const offsetX = pixelX - segment.start.x;
|
||||||
|
const offsetY = pixelY - segment.start.y;
|
||||||
|
const projection =
|
||||||
|
(offsetX * segment.deltaX + offsetY * segment.deltaY) *
|
||||||
|
segment.inverseSquaredLength;
|
||||||
|
const amount = Math.max(0, Math.min(1, projection));
|
||||||
|
const squaredDistance =
|
||||||
|
(offsetX - segment.deltaX * amount) ** 2 +
|
||||||
|
(offsetY - segment.deltaY * amount) ** 2;
|
||||||
|
if (squaredDistance >= nearestSquaredDistance) continue;
|
||||||
|
|
||||||
|
nearestSquaredDistance = squaredDistance;
|
||||||
|
arrivalDistance = segment.start.distance + amount * segment.length;
|
||||||
|
if (index === 0 && projection < 0) {
|
||||||
|
arrivalDistance = Math.max(-ENTRY_PADDING, projection * segment.length);
|
||||||
|
}
|
||||||
|
if (index === segments.length - 1 && projection > 1) {
|
||||||
|
arrivalDistance = Math.min(
|
||||||
|
pathLength + EXIT_PADDING,
|
||||||
|
segment.start.distance + projection * segment.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nearestDistance = Math.sqrt(nearestSquaredDistance);
|
||||||
|
const feather = Math.min(
|
||||||
|
MAX_FEATHER,
|
||||||
|
16 + nearestDistance * 0.9
|
||||||
|
);
|
||||||
|
revealStarts[pixel] = arrivalDistance;
|
||||||
|
revealRates[pixel] = 1 / feather;
|
||||||
|
sweepTrackAlpha[pixel] =
|
||||||
|
1 -
|
||||||
|
smoothStep(
|
||||||
|
(nearestDistance - SWEEP_TRACK_RADIUS) / SWEEP_TRACK_FEATHER
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const temporary = new Float32Array(revealStarts.length);
|
||||||
|
const radius = 3;
|
||||||
|
for (let pass = 0; pass < 3; pass += 1) {
|
||||||
|
for (let row = 0; row < MASK_HEIGHT; row += 1) {
|
||||||
|
for (let column = 0; column < MASK_WIDTH; column += 1) {
|
||||||
|
let sum = 0;
|
||||||
|
for (let offset = -radius; offset <= radius; offset += 1) {
|
||||||
|
const neighbor = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(MASK_WIDTH - 1, column + offset)
|
||||||
|
);
|
||||||
|
sum += revealStarts[row * MASK_WIDTH + neighbor];
|
||||||
|
}
|
||||||
|
temporary[row * MASK_WIDTH + column] = sum / (radius * 2 + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (let row = 0; row < MASK_HEIGHT; row += 1) {
|
||||||
|
for (let column = 0; column < MASK_WIDTH; column += 1) {
|
||||||
|
let sum = 0;
|
||||||
|
for (let offset = -radius; offset <= radius; offset += 1) {
|
||||||
|
const neighbor = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(MASK_HEIGHT - 1, row + offset)
|
||||||
|
);
|
||||||
|
sum += temporary[neighbor * MASK_WIDTH + column];
|
||||||
|
}
|
||||||
|
revealStarts[row * MASK_WIDTH + column] = sum / (radius * 2 + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackPosition = (distance) => {
|
||||||
|
const boundedDistance = Math.max(0, Math.min(pathLength, distance));
|
||||||
|
let upper = 1;
|
||||||
|
while (
|
||||||
|
upper < track.length - 1 &&
|
||||||
|
track[upper].distance < boundedDistance
|
||||||
|
) {
|
||||||
|
upper += 1;
|
||||||
|
}
|
||||||
|
const start = track[upper - 1];
|
||||||
|
const end = track[upper];
|
||||||
|
const amount =
|
||||||
|
(boundedDistance - start.distance) / (end.distance - start.distance);
|
||||||
|
return {
|
||||||
|
x: start.x + (end.x - start.x) * amount,
|
||||||
|
y: start.y + (end.y - start.y) * amount,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 满轨素材的视觉中心比空轨低约 3px,所有满轨绘制统一上移补偿。
|
||||||
|
const drawFullTrack = (targetContext) => {
|
||||||
|
targetContext.drawImage(
|
||||||
|
images[1],
|
||||||
|
0,
|
||||||
|
FULL_TRACK_OFFSET_Y,
|
||||||
|
WIDTH,
|
||||||
|
HEIGHT
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const maskedImage = (layer, image, operation, offsetY = 0) => {
|
||||||
|
layer.context.globalCompositeOperation = "source-over";
|
||||||
|
layer.context.clearRect(0, 0, WIDTH, HEIGHT);
|
||||||
|
layer.context.drawImage(image, 0, offsetY, WIDTH, HEIGHT);
|
||||||
|
layer.context.globalCompositeOperation = operation;
|
||||||
|
layer.context.drawImage(maskLayer.canvas, 0, 0, WIDTH, HEIGHT);
|
||||||
|
layer.context.globalCompositeOperation = "source-over";
|
||||||
|
};
|
||||||
|
|
||||||
|
const draw = (progress) => {
|
||||||
|
context.globalCompositeOperation = "source-over";
|
||||||
|
context.clearRect(0, 0, WIDTH, HEIGHT);
|
||||||
|
|
||||||
|
if (progress <= 0) {
|
||||||
|
context.drawImage(images[0], 0, 0, WIDTH, HEIGHT);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (progress >= 100) {
|
||||||
|
drawFullTrack(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 真实百分比只映射轨道本体,避免 90%~100% 被出口和羽化缓冲吞掉。
|
||||||
|
const distance = (progress / 100) * pathLength;
|
||||||
|
for (let pixel = 0; pixel < revealStarts.length; pixel += 1) {
|
||||||
|
maskPixels.data[pixel * 4 + 3] = Math.round(
|
||||||
|
255 * smoothStep((distance - revealStarts[pixel]) * revealRates[pixel])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
maskLayer.context.putImageData(maskPixels, 0, 0);
|
||||||
|
maskedImage(emptyLayer, images[0], "destination-out");
|
||||||
|
maskedImage(
|
||||||
|
fullLayer,
|
||||||
|
images[1],
|
||||||
|
"destination-in",
|
||||||
|
FULL_TRACK_OFFSET_Y
|
||||||
|
);
|
||||||
|
context.drawImage(emptyLayer.canvas, 0, 0);
|
||||||
|
context.globalCompositeOperation = "lighter";
|
||||||
|
context.drawImage(fullLayer.canvas, 0, 0);
|
||||||
|
context.globalCompositeOperation = "source-over";
|
||||||
|
|
||||||
|
const headBlend =
|
||||||
|
smoothStep(progress / 10) * (1 - smoothStep((progress - 85) / 15));
|
||||||
|
const position = trackPosition(distance - 16);
|
||||||
|
const headSize = 72 * (0.55 + 0.45 * headBlend);
|
||||||
|
glowLayer.context.clearRect(0, 0, WIDTH, HEIGHT);
|
||||||
|
glowLayer.context.drawImage(
|
||||||
|
images[2],
|
||||||
|
position.x - headSize / 2,
|
||||||
|
position.y - headSize / 2,
|
||||||
|
headSize,
|
||||||
|
headSize
|
||||||
|
);
|
||||||
|
glowLayer.context.globalCompositeOperation = "destination-in";
|
||||||
|
drawFullTrack(glowLayer.context);
|
||||||
|
glowLayer.context.globalCompositeOperation = "source-over";
|
||||||
|
context.globalAlpha = 0.8 * headBlend;
|
||||||
|
context.drawImage(glowLayer.canvas, 0, 0);
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawSweep = (amount) => {
|
||||||
|
context.globalCompositeOperation = "source-over";
|
||||||
|
context.clearRect(0, 0, WIDTH, HEIGHT);
|
||||||
|
drawFullTrack(context);
|
||||||
|
|
||||||
|
const lead = pathLength * 0.03;
|
||||||
|
const core = pathLength * 0.08;
|
||||||
|
const tail = pathLength * 0.18;
|
||||||
|
const sweepCenter = -tail + amount * (pathLength + lead + tail);
|
||||||
|
for (let pixel = 0; pixel < revealStarts.length; pixel += 1) {
|
||||||
|
const offset = sweepCenter - revealStarts[pixel];
|
||||||
|
let intensity = 0;
|
||||||
|
if (offset >= -lead && offset < 0) {
|
||||||
|
intensity = smoothStep(1 + offset / lead);
|
||||||
|
} else if (offset >= 0 && offset <= core) {
|
||||||
|
intensity = 1;
|
||||||
|
} else if (offset > core && offset <= core + tail) {
|
||||||
|
intensity = 1 - smoothStep((offset - core) / tail);
|
||||||
|
}
|
||||||
|
maskPixels.data[pixel * 4 + 3] = Math.round(
|
||||||
|
255 * intensity * sweepTrackAlpha[pixel]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
maskLayer.context.putImageData(maskPixels, 0, 0);
|
||||||
|
|
||||||
|
sweepLayer.context.globalCompositeOperation = "source-over";
|
||||||
|
sweepLayer.context.clearRect(0, 0, WIDTH, HEIGHT);
|
||||||
|
drawFullTrack(sweepLayer.context);
|
||||||
|
sweepLayer.context.globalCompositeOperation = "source-in";
|
||||||
|
sweepLayer.context.fillStyle = "#fffbd8";
|
||||||
|
sweepLayer.context.fillRect(0, 0, WIDTH, HEIGHT);
|
||||||
|
sweepLayer.context.globalCompositeOperation = "destination-in";
|
||||||
|
sweepLayer.context.drawImage(maskLayer.canvas, 0, 0, WIDTH, HEIGHT);
|
||||||
|
sweepLayer.context.globalCompositeOperation = "source-over";
|
||||||
|
|
||||||
|
// 普通透明叠加只提亮轨道核心,不把满轨素材的外围光晕染白。
|
||||||
|
context.globalCompositeOperation = "source-over";
|
||||||
|
context.globalAlpha = 0.9;
|
||||||
|
context.drawImage(sweepLayer.canvas, 0, 0);
|
||||||
|
context.globalAlpha = 1;
|
||||||
|
context.globalCompositeOperation = "source-over";
|
||||||
|
};
|
||||||
|
|
||||||
|
return { draw, drawSweep };
|
||||||
|
};
|
||||||
|
|
||||||
|
const runCompletionSweep = () => {
|
||||||
|
cancelFrame();
|
||||||
|
let startedAt = null;
|
||||||
|
const step = (timestamp) => {
|
||||||
|
if (!engine || disposed) return;
|
||||||
|
if (startedAt === null) startedAt = timestamp;
|
||||||
|
const elapsed = timestamp - startedAt;
|
||||||
|
if (elapsed < SWEEP_DELAY) {
|
||||||
|
engine.draw(100);
|
||||||
|
animationHandle = requestFrame(step);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const amount = Math.min(1, (elapsed - SWEEP_DELAY) / SWEEP_DURATION);
|
||||||
|
engine.drawSweep(amount);
|
||||||
|
if (amount < 1) {
|
||||||
|
animationHandle = requestFrame(step);
|
||||||
|
} else {
|
||||||
|
animationHandle = null;
|
||||||
|
engine.draw(100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
animationHandle = requestFrame(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 服务端能量既会因命中增加,也会按秒下降,因此双向过渡都以最新快照为准。
|
||||||
|
const drawPercent = (targetPercent, animate = true) => {
|
||||||
|
if (!engine) return;
|
||||||
|
cancelFrame();
|
||||||
|
const from = renderedPercent;
|
||||||
|
const target = Math.max(0, Math.min(100, targetPercent));
|
||||||
|
const reachesFull = from < 100 && target >= 100;
|
||||||
|
|
||||||
|
if (!animate || Math.abs(target - from) < 0.01) {
|
||||||
|
renderedPercent = target;
|
||||||
|
engine.draw(target);
|
||||||
|
if (reachesFull) runCompletionSweep();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let startedAt = null;
|
||||||
|
const step = (timestamp) => {
|
||||||
|
if (!engine || disposed) return;
|
||||||
|
if (startedAt === null) startedAt = timestamp;
|
||||||
|
const amount = Math.min(1, (timestamp - startedAt) / UPDATE_DURATION);
|
||||||
|
renderedPercent = from + (target - from) * smoothStep(amount);
|
||||||
|
engine.draw(renderedPercent);
|
||||||
|
if (amount < 1) {
|
||||||
|
animationHandle = requestFrame(step);
|
||||||
|
} else {
|
||||||
|
animationHandle = null;
|
||||||
|
renderedPercent = target;
|
||||||
|
engine.draw(target);
|
||||||
|
if (reachesFull) runCompletionSweep();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
animationHandle = requestFrame(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 首次进入时先展示满能量,再用 1 秒线性动画回落到 0%。
|
||||||
|
const runEntryAnimation = () => {
|
||||||
|
if (!engine || disposed) return;
|
||||||
|
cancelFrame();
|
||||||
|
entryAnimationPlaying = true;
|
||||||
|
renderedPercent = 100;
|
||||||
|
engine.draw(100);
|
||||||
|
canvasReady.value = true;
|
||||||
|
|
||||||
|
let startedAt = null;
|
||||||
|
const step = (timestamp) => {
|
||||||
|
if (!engine || disposed) return;
|
||||||
|
if (startedAt === null) startedAt = timestamp;
|
||||||
|
const amount = Math.min(
|
||||||
|
1,
|
||||||
|
(timestamp - startedAt) / ENTRY_ANIMATION_DURATION
|
||||||
|
);
|
||||||
|
renderedPercent = 100 * (1 - amount);
|
||||||
|
engine.draw(renderedPercent);
|
||||||
|
|
||||||
|
if (amount < 1) {
|
||||||
|
animationHandle = requestFrame(step);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
animationHandle = null;
|
||||||
|
entryAnimationPlaying = false;
|
||||||
|
shouldPlayEntryAnimation.value = false;
|
||||||
|
renderedPercent = 0;
|
||||||
|
engine.draw(0);
|
||||||
|
|
||||||
|
// 动画期间可能收到服务端快照,结束后再追到最新真实能量。
|
||||||
|
if (normalizedPercent.value > 0) {
|
||||||
|
drawPercent(normalizedPercent.value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
animationHandle = requestFrame(step);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialize = async () => {
|
||||||
|
await nextTick();
|
||||||
|
const info = await new Promise((resolve) => {
|
||||||
|
let query = uni.createSelectorQuery();
|
||||||
|
if (instance?.proxy && typeof query.in === "function") {
|
||||||
|
query = query.in(instance.proxy);
|
||||||
|
}
|
||||||
|
query
|
||||||
|
.select(`#${canvasId}`)
|
||||||
|
.fields({ node: true, size: true })
|
||||||
|
.exec((result) => resolve(result?.[0] || null));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (disposed) return;
|
||||||
|
if (!info?.node?.getContext) {
|
||||||
|
shouldPlayEntryAnimation.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
engine = await createEnergyEngine(info.node);
|
||||||
|
if (disposed) return;
|
||||||
|
if (shouldPlayEntryAnimation.value) {
|
||||||
|
runEntryAnimation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderedPercent = normalizedPercent.value;
|
||||||
|
engine.draw(renderedPercent);
|
||||||
|
canvasReady.value = true;
|
||||||
|
} catch (error) {
|
||||||
|
// Canvas 能力不可用时保留单张兜底图,不影响训练业务。
|
||||||
|
shouldPlayEntryAnimation.value = false;
|
||||||
|
console.log("stability energy canvas unavailable", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(normalizedPercent, (value) => {
|
||||||
|
if (!engine) {
|
||||||
|
// Canvas 初始化前已经恢复出真实能量时,视为重连并跳过入场归零动画。
|
||||||
|
if (value > 0) shouldPlayEntryAnimation.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (entryAnimationPlaying) return;
|
||||||
|
drawPercent(value);
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(initialize);
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disposed = true;
|
||||||
|
cancelFrame();
|
||||||
|
engine = null;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="stability-energy-track" aria-label="稳定训练能量进度">
|
||||||
|
<image
|
||||||
|
v-if="!canvasReady"
|
||||||
|
:class="[
|
||||||
|
'stability-energy-track__image',
|
||||||
|
shouldPlayEntryAnimation
|
||||||
|
? 'stability-energy-track__image--full'
|
||||||
|
: '',
|
||||||
|
]"
|
||||||
|
:src="fallbackImageSource"
|
||||||
|
mode="aspectFit"
|
||||||
|
/>
|
||||||
|
<canvas
|
||||||
|
:id="canvasId"
|
||||||
|
:canvas-id="canvasId"
|
||||||
|
class="stability-energy-track__canvas"
|
||||||
|
type="2d"
|
||||||
|
:width="WIDTH"
|
||||||
|
:height="HEIGHT"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.stability-energy-track {
|
||||||
|
position: relative;
|
||||||
|
z-index: 4;
|
||||||
|
width: 100%;
|
||||||
|
height: 228rpx;
|
||||||
|
margin: -164rpx 0 -80rpx;
|
||||||
|
margin-left: -10rpx;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stability-energy-track__image,
|
||||||
|
.stability-energy-track__canvas {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stability-energy-track__image--full {
|
||||||
|
transform: translateY(-3rpx);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,7 @@ import { computed, ref, onMounted, onBeforeUnmount } from "vue";
|
|||||||
import { onHide, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
import { onHide, onLoad, onShow, onUnload } from "@dcloudio/uni-app";
|
||||||
import Container from "@/components/Container.vue";
|
import Container from "@/components/Container.vue";
|
||||||
import ShootProgress from "./components/ShootProgress.vue";
|
import ShootProgress from "./components/ShootProgress.vue";
|
||||||
|
import StabilityEnergyTrack from "./components/StabilityEnergyTrack.vue";
|
||||||
import BowTarget from "./components/BowTarget.vue";
|
import BowTarget from "./components/BowTarget.vue";
|
||||||
import ScorePanel2 from "@/components/TrainingScorePanel.vue";
|
import ScorePanel2 from "@/components/TrainingScorePanel.vue";
|
||||||
import ScoreResult from "./components/ScoreResult.vue";
|
import ScoreResult from "./components/ScoreResult.vue";
|
||||||
@@ -105,14 +106,14 @@ const rhythmTargetArrows = ref(0);
|
|||||||
// 服务端状态立即落到 practiceInfo,精准训练的目标区域单独延迟展示。
|
// 服务端状态立即落到 practiceInfo,精准训练的目标区域单独延迟展示。
|
||||||
const visiblePrecisionTarget = ref({
|
const visiblePrecisionTarget = ref({
|
||||||
randomBlock: 0,
|
randomBlock: 0,
|
||||||
randomRingArea: 0,
|
randomRingAreas: [],
|
||||||
});
|
});
|
||||||
const trainingDifficultyStorageKey = "training-selection";
|
const trainingDifficultyStorageKey = "training-selection";
|
||||||
const useHighlightTest = ref(false);
|
const useHighlightTest = ref(false);
|
||||||
const highlightTestState = ref({
|
const highlightTestState = ref({
|
||||||
blocks: 8,
|
blocks: 8,
|
||||||
randomBlock: 1,
|
randomBlock: 1,
|
||||||
randomRingArea: 0,
|
randomRingAreas: [],
|
||||||
});
|
});
|
||||||
const highlightTestTimer = ref(null);
|
const highlightTestTimer = ref(null);
|
||||||
const serverAddr = ref("");
|
const serverAddr = ref("");
|
||||||
@@ -195,9 +196,23 @@ const getPositiveInteger = (value) => {
|
|||||||
return Number.isInteger(numberValue) && numberValue > 0 ? numberValue : 0;
|
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 = {}) => ({
|
const getPrecisionTargetSnapshot = (source = {}) => ({
|
||||||
randomBlock: getPositiveInteger(source.randomBlock),
|
randomBlock: getPositiveInteger(source.randomBlock),
|
||||||
randomRingArea: getPositiveInteger(source.randomRingArea),
|
// 多环字段优先;后端仅在单环时继续下发旧单值字段。
|
||||||
|
randomRingAreas: normalizePrecisionRingAreas(source),
|
||||||
});
|
});
|
||||||
|
|
||||||
const applyVisiblePrecisionTarget = (source = {}) => {
|
const applyVisiblePrecisionTarget = (source = {}) => {
|
||||||
@@ -345,6 +360,15 @@ const stabilityEnergyPercent = computed(() => {
|
|||||||
(stabilityCurrentEnergy.value / stabilityScoreSlot.value) * 100
|
(stabilityCurrentEnergy.value / stabilityScoreSlot.value) * 100
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
const stabilityEnergyCostPerSec = computed(() =>
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
getPracticeNumber(
|
||||||
|
practiceInfo.value.energyCostPerSec,
|
||||||
|
trainingParams.value.energyCostPerSec
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const initializeRhythmFirstRoundCountdown = () => {
|
const initializeRhythmFirstRoundCountdown = () => {
|
||||||
rhythmHasActiveServerAnchor.value = false;
|
rhythmHasActiveServerAnchor.value = false;
|
||||||
@@ -384,14 +408,13 @@ const precisionRandomBlock = computed(() => {
|
|||||||
return block <= precisionBlocks.value ? block : 0;
|
return block <= precisionBlocks.value ? block : 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
const precisionRandomRingArea = computed(() => {
|
const precisionRandomRingAreas = computed(() =>
|
||||||
const ring = getPositiveInteger(
|
normalizePrecisionRingAreas(
|
||||||
useHighlightTest.value
|
useHighlightTest.value
|
||||||
? highlightTestState.value.randomRingArea
|
? highlightTestState.value
|
||||||
: visiblePrecisionTarget.value.randomRingArea
|
: visiblePrecisionTarget.value
|
||||||
|
)
|
||||||
);
|
);
|
||||||
return ring >= 1 && ring <= 10 ? ring : 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 只展示后端进度,不在前端重复判断训练是否完成。
|
// 只展示后端进度,不在前端重复判断训练是否完成。
|
||||||
const trainingCopy = computed(() => {
|
const trainingCopy = computed(() => {
|
||||||
@@ -555,6 +578,7 @@ const practiceInfoFields = [
|
|||||||
"blocks",
|
"blocks",
|
||||||
"randomBlock",
|
"randomBlock",
|
||||||
"randomRingArea",
|
"randomRingArea",
|
||||||
|
"randomRingAreas",
|
||||||
"roundTime",
|
"roundTime",
|
||||||
"shootTime",
|
"shootTime",
|
||||||
"shootWindowStart",
|
"shootWindowStart",
|
||||||
@@ -746,6 +770,9 @@ const syncPracticeInfo = (message = {}) => {
|
|||||||
if (!Object.prototype.hasOwnProperty.call(message, "randomRingArea")) {
|
if (!Object.prototype.hasOwnProperty.call(message, "randomRingArea")) {
|
||||||
nextInfo.randomRingArea = 0;
|
nextInfo.randomRingArea = 0;
|
||||||
}
|
}
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(message, "randomRingAreas")) {
|
||||||
|
nextInfo.randomRingAreas = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(nextInfo).length === 0) return;
|
if (Object.keys(nextInfo).length === 0) return;
|
||||||
@@ -1290,7 +1317,7 @@ const clearHighlightTestTimer = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 开发环境测试入口:依次切换 8 个顺时针区域,偶数区域只高亮指定环。
|
// 开发环境测试入口:依次切换 8 个顺时针区域,偶数区域同时高亮两个环。
|
||||||
const runHighlightTest = () => {
|
const runHighlightTest = () => {
|
||||||
clearHighlightTestTimer();
|
clearHighlightTestTimer();
|
||||||
useHighlightTest.value = true;
|
useHighlightTest.value = true;
|
||||||
@@ -1302,7 +1329,7 @@ const runHighlightTest = () => {
|
|||||||
highlightTestState.value = {
|
highlightTestState.value = {
|
||||||
blocks: 8,
|
blocks: 8,
|
||||||
randomBlock: block,
|
randomBlock: block,
|
||||||
randomRingArea: 0,
|
randomRingAreas: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
highlightTestTimer.value = setInterval(() => {
|
highlightTestTimer.value = setInterval(() => {
|
||||||
@@ -1315,7 +1342,10 @@ const runHighlightTest = () => {
|
|||||||
highlightTestState.value = {
|
highlightTestState.value = {
|
||||||
blocks: 8,
|
blocks: 8,
|
||||||
randomBlock: block,
|
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);
|
}, 1000);
|
||||||
};
|
};
|
||||||
@@ -1326,7 +1356,7 @@ const resetHighlightTest = () => {
|
|||||||
highlightTestState.value = {
|
highlightTestState.value = {
|
||||||
blocks: 8,
|
blocks: 8,
|
||||||
randomBlock: 1,
|
randomBlock: 1,
|
||||||
randomRingArea: 0,
|
randomRingAreas: [],
|
||||||
};
|
};
|
||||||
scores.value = [];
|
scores.value = [];
|
||||||
};
|
};
|
||||||
@@ -1790,8 +1820,7 @@ onBeforeUnmount(() => {
|
|||||||
:inShootWindow="rhythmInShootWindow"
|
:inShootWindow="rhythmInShootWindow"
|
||||||
:serverTimestamp="rhythmCountdownTimestamp"
|
:serverTimestamp="rhythmCountdownTimestamp"
|
||||||
:hitReq="rhythmHitReq"
|
:hitReq="rhythmHitReq"
|
||||||
:energyPercent="stabilityEnergyPercent"
|
:energyCostPerSec="stabilityEnergyCostPerSec"
|
||||||
:energyReqPercent="stabilityEnergyReqPercent"
|
|
||||||
:isVip="isVip"
|
:isVip="isVip"
|
||||||
:isSvip="isSvip"
|
:isSvip="isSvip"
|
||||||
:externalShootResultAudio="
|
:externalShootResultAudio="
|
||||||
@@ -1819,11 +1848,15 @@ onBeforeUnmount(() => {
|
|||||||
:showCrosshair="false"
|
:showCrosshair="false"
|
||||||
:sectorCount="precisionBlocks"
|
:sectorCount="precisionBlocks"
|
||||||
:activeSector="precisionRandomBlock"
|
:activeSector="precisionRandomBlock"
|
||||||
:activeRing="precisionRandomRingArea"
|
:activeRings="precisionRandomRingAreas"
|
||||||
:highlightRefreshToken="precisionTargetRefreshToken"
|
:highlightRefreshToken="precisionTargetRefreshToken"
|
||||||
stable-shot-effect
|
stable-shot-effect
|
||||||
@shot-effect-complete="onShotEffectComplete"
|
@shot-effect-complete="onShotEffectComplete"
|
||||||
/>
|
/>
|
||||||
|
<StabilityEnergyTrack
|
||||||
|
v-if="isStabilityTraining"
|
||||||
|
:percent="stabilityEnergyPercent"
|
||||||
|
/>
|
||||||
<!-- <view v-if="env !== 'release'" class="highlight-test-actions">
|
<!-- <view v-if="env !== 'release'" class="highlight-test-actions">
|
||||||
<button
|
<button
|
||||||
class="highlight-test-btn"
|
class="highlight-test-btn"
|
||||||
@@ -2019,6 +2052,8 @@ onBeforeUnmount(() => {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
|
position: relative;
|
||||||
|
z-index: 6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sound-btn::after {
|
.sound-btn::after {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -2,13 +2,23 @@ import protobuf from "protobufjs/minimal.js";
|
|||||||
|
|
||||||
const { Reader, Writer } = protobuf;
|
const { Reader, Writer } = protobuf;
|
||||||
|
|
||||||
|
// protobuf 会将这些 repeated 标量默认编码为 packed(length-delimited),
|
||||||
|
// 同时也允许发送端使用逐项的 unpacked 编码。
|
||||||
|
const PACKABLE_SCALAR_KINDS = new Set([
|
||||||
|
"int32",
|
||||||
|
"int64",
|
||||||
|
"float",
|
||||||
|
"double",
|
||||||
|
"bool",
|
||||||
|
]);
|
||||||
|
|
||||||
// 比赛服 protobuf 协议适配层:
|
// 比赛服 protobuf 协议适配层:
|
||||||
// 小程序环境不支持 protobufjs 反射模式里的动态 Function codegen,
|
// 小程序环境不支持 protobufjs 反射模式里的动态 Function codegen,
|
||||||
// 所以这里使用 minimal Reader/Writer 做静态字段解码和客户端消息编码。
|
// 所以这里使用 minimal Reader/Writer 做静态字段解码和客户端消息编码。
|
||||||
// <match-schema-generated>
|
// <match-schema-generated>
|
||||||
// 此区块由 scripts/generate-match-schema.mjs 自动生成,请勿手动修改。
|
// 此区块由 scripts/generate-match-schema.mjs 自动生成,请勿手动修改。
|
||||||
// 来源:src/utils/match.min.js(sha256: ec0371baeba9a9bf)
|
// 来源:src/utils/match.min.js(sha256: 5b72fd5f9e087921)
|
||||||
// 协议命名空间:rpc;消息数:12;字段数:163
|
// 协议命名空间:rpc;消息数:12;字段数:164
|
||||||
|
|
||||||
export const ServerMessageType = {
|
export const ServerMessageType = {
|
||||||
SERVER_MSG_UNKNOWN: 0,
|
SERVER_MSG_UNKNOWN: 0,
|
||||||
@@ -206,6 +216,7 @@ const SCHEMAS = {
|
|||||||
61: { name: "delta_qualified_rate", kind: "int32" },
|
61: { name: "delta_qualified_rate", kind: "int32" },
|
||||||
62: { name: "hit_rate", kind: "int32" },
|
62: { name: "hit_rate", kind: "int32" },
|
||||||
63: { name: "delta_hit_rate", kind: "int32" },
|
63: { name: "delta_hit_rate", kind: "int32" },
|
||||||
|
64: { name: "random_ring_areas", kind: "int32", repeated: true },
|
||||||
},
|
},
|
||||||
MatchInfo: {
|
MatchInfo: {
|
||||||
1: { name: "match_id", kind: "string" },
|
1: { name: "match_id", kind: "string" },
|
||||||
@@ -341,6 +352,20 @@ function readValue(reader, field) {
|
|||||||
return readScalar(reader, field.kind);
|
return readScalar(reader, field.kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readPackedValues(reader, field) {
|
||||||
|
const end = reader.uint32() + reader.pos;
|
||||||
|
const values = [];
|
||||||
|
|
||||||
|
while (reader.pos < end) {
|
||||||
|
values.push(readScalar(reader, field.kind));
|
||||||
|
}
|
||||||
|
if (reader.pos !== end) {
|
||||||
|
throw new Error(`Invalid packed protobuf field: ${field.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
function decodeMessage(schemaName, readerOrData, length) {
|
function decodeMessage(schemaName, readerOrData, length) {
|
||||||
const schema = SCHEMAS[schemaName];
|
const schema = SCHEMAS[schemaName];
|
||||||
const reader =
|
const reader =
|
||||||
@@ -351,9 +376,10 @@ function decodeMessage(schemaName, readerOrData, length) {
|
|||||||
while (reader.pos < end) {
|
while (reader.pos < end) {
|
||||||
const tag = reader.uint32();
|
const tag = reader.uint32();
|
||||||
const field = schema[tag >>> 3];
|
const field = schema[tag >>> 3];
|
||||||
|
const wireType = tag & 7;
|
||||||
|
|
||||||
if (!field) {
|
if (!field) {
|
||||||
reader.skipType(tag & 7);
|
reader.skipType(wireType);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,6 +391,17 @@ function decodeMessage(schemaName, readerOrData, length) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
field.repeated &&
|
||||||
|
PACKABLE_SCALAR_KINDS.has(field.kind) &&
|
||||||
|
wireType === 2
|
||||||
|
) {
|
||||||
|
const values = message[field.name] || [];
|
||||||
|
values.push(...readPackedValues(reader, field));
|
||||||
|
message[field.name] = values;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const value = readValue(reader, field);
|
const value = readValue(reader, field);
|
||||||
|
|
||||||
if (field.repeated) {
|
if (field.repeated) {
|
||||||
|
|||||||
Reference in New Issue
Block a user