Merge branch 'feat-training' into test

This commit is contained in:
2026-09-23 10:50:06 +08:00
12 changed files with 945 additions and 132 deletions
-5
View File
@@ -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 }
+50 -28
View File
@@ -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,32 +293,35 @@ 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,
}; };
drawAnnularSector( highlightRanges.forEach((range) => {
ctx, const innerRadius =
centerX, targetRadius * ((config.ringCount - range.end) / config.ringCount);
centerY, const outerRadius =
innerRadius, targetRadius * ((config.ringCount + 1 - range.start) / config.ringCount);
outerRadius,
angles.startAngle, drawAnnularSector(
angles.startAngle + ctx,
(angles.endAngle - angles.startAngle) * safeRevealProgress, centerX,
style.color, centerY,
style.strokeColor, innerRadius,
Math.max(1, targetRadius * style.lineWidthRatio) 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, 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,
+5 -5
View File
@@ -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 {
+23 -53
View File
@@ -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 }}点能量
</text>
<view v-if="countdownEnabled" class="stability-progress__countdown">
<view <view
class="stability-progress__fill" class="stability-progress__countdown-fill"
:class="{ :style="{
'stability-progress__fill--reached': stabilityEnergyReached, width: `${progressPercent}%`,
transition: transitionStyle,
}" }"
:style="{ width: `${stabilityEnergyPercent}%` }"
/> />
<view <text class="stability-progress__countdown-text">剩余{{ remain }}</text>
class="stability-progress__marker"
:style="{ left: `${stabilityEnergyReqPercent}%` }"
/>
<text class="stability-progress__value">
{{ Math.round(stabilityEnergyPercent) }}%
</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,730 @@
<script setup>
import {
computed,
getCurrentInstance,
nextTick,
onBeforeUnmount,
onMounted,
ref,
watch,
} from "vue";
const props = defineProps({
percent: {
type: Number,
default: 0,
},
introPending: {
type: Boolean,
default: false,
},
});
const WIDTH = 750;
const HEIGHT = 228;
const MASK_SCALE = 2;
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 ENTRY_EDGE_BLEND_PERCENT = 5;
const EXIT_EDGE_BLEND_START_PERCENT = 90;
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 =
"/pages/training/static/stability-energy";
const getAssetSource = (filename) => `${ASSET_ROOT}/${filename}`;
const instance = getCurrentInstance();
const canvasId = `stability-energy-${Math.random().toString(36).slice(2, 10)}`;
const canvasReady = ref(false);
const fallbackVisible = ref(true);
const normalizedPercent = computed(() =>
Math.max(0, Math.min(100, Number(props.percent) || 0))
);
let engine = null;
let disposed = false;
let animationHandle = null;
let renderedPercent = props.introPending ? 100 : normalizedPercent.value;
let entryAnimationPlaying = false;
let entryAnimationRequested = false;
const showCanvas = () => {
canvasReady.value = true;
};
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 waitForPaint = () =>
new Promise((resolve) => {
const schedule = (callback) => {
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(callback);
} else {
setTimeout(callback, 16);
}
};
schedule(() => schedule(resolve));
});
const presentCanvas = async () => {
showCanvas();
await nextTick();
await waitForPaint();
if (disposed) return false;
fallbackVisible.value = false;
return true;
};
const smoothStep = (value) => {
const amount = Math.max(0, Math.min(1, value));
return amount * amount * (3 - 2 * amount);
};
const enableImageSmoothing = (context) => {
if ("imageSmoothingEnabled" in context) {
context.imageSmoothingEnabled = true;
}
if ("imageSmoothingQuality" in context) {
context.imageSmoothingQuality = "high";
}
};
// 复用参考 HTML 的贝塞尔路径,保证能量光带沿靶纸下方的弧线推进。
const sampleTrack = () => {
const curves = [
// 新素材移除了左上方延伸段,进度从箭头端点开始进入弯道。
[171, 53, 145, 56, 123, 68, 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: 171, y: 53, 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");
enableImageSmoothing(context);
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, preloadedFullImage = null) => {
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas unavailable");
enableImageSmoothing(context);
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, getAssetSource("progress-empty.png")),
preloadedFullImage ||
loadCanvasImage(canvas, getAssetSource("progress-full.png")),
loadCanvasImage(canvas, getAssetSource("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;
}
// 主体百分比仍映射轨道本体,头尾仅补偿端点羽化,避免整图与蒙版切换时跳变。
const distance = (progress / 100) * pathLength;
const entryBlend = smoothStep(progress / ENTRY_EDGE_BLEND_PERCENT);
const exitBlend = smoothStep(
(progress - EXIT_EDGE_BLEND_START_PERCENT) /
(100 - EXIT_EDGE_BLEND_START_PERCENT)
);
for (let pixel = 0; pixel < revealStarts.length; pixel += 1) {
const trackAlpha =
smoothStep(
(distance - revealStarts[pixel]) * revealRates[pixel]
) * entryBlend;
maskPixels.data[pixel * 4 + 3] = Math.round(
255 * (trackAlpha + (1 - trackAlpha) * exitBlend)
);
}
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 = Math.round(72 * (0.55 + 0.45 * headBlend));
const headX = Math.round(position.x - headSize / 2);
const headY = Math.round(position.y - headSize / 2);
glowLayer.context.clearRect(0, 0, WIDTH, HEIGHT);
glowLayer.context.drawImage(
images[2],
headX,
headY,
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();
entryAnimationRequested = false;
entryAnimationPlaying = true;
renderedPercent = 100;
engine.draw(100);
showCanvas();
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;
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) {
return;
}
try {
const canvas = info.node;
canvas.width = WIDTH;
canvas.height = HEIGHT;
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas unavailable");
enableImageSmoothing(context);
let preloadedFullImage = null;
if (props.introPending) {
preloadedFullImage = await loadCanvasImage(
canvas,
getAssetSource("progress-full.png")
);
if (disposed) return;
context.clearRect(0, 0, WIDTH, HEIGHT);
context.drawImage(
preloadedFullImage,
0,
FULL_TRACK_OFFSET_Y,
WIDTH,
HEIGHT
);
renderedPercent = 100;
if (!(await presentCanvas())) return;
// 先让满能量首帧完成合成,再开始较重的遮罩和轨道计算。
await new Promise((resolve) => setTimeout(resolve, 0));
if (disposed) return;
}
engine = await createEnergyEngine(canvas, preloadedFullImage);
if (disposed) return;
if (props.introPending || entryAnimationRequested) {
renderedPercent = 100;
engine.draw(100);
showCanvas();
if (!props.introPending && entryAnimationRequested) {
runEntryAnimation();
}
return;
}
renderedPercent = normalizedPercent.value;
engine.draw(renderedPercent);
await presentCanvas();
} catch (error) {
console.log("stability energy canvas unavailable", error);
}
};
watch(normalizedPercent, (value) => {
if (!engine || props.introPending || entryAnimationPlaying) return;
drawPercent(value);
});
watch(
() => props.introPending,
(pending, wasPending) => {
if (pending) {
entryAnimationRequested = false;
if (!engine) return;
cancelFrame();
entryAnimationPlaying = false;
renderedPercent = 100;
engine.draw(100);
showCanvas();
return;
}
if (!wasPending) return;
entryAnimationRequested = true;
if (engine) runEntryAnimation();
}
);
onMounted(initialize);
onBeforeUnmount(() => {
disposed = true;
cancelFrame();
engine = null;
});
</script>
<template>
<view class="stability-energy-track" aria-label="稳定训练能量进度">
<image
v-if="fallbackVisible"
class="stability-energy-track__image"
:src="getAssetSource('progress-full.png')"
mode="scaleToFill"
/>
<canvas
:id="canvasId"
:canvas-id="canvasId"
:class="[
'stability-energy-track__canvas',
canvasReady ? 'stability-energy-track__canvas--ready' : '',
]"
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;
overflow: hidden;
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 {
z-index: 0;
transform: translateY(-3rpx);
}
.stability-energy-track__canvas {
z-index: 1;
opacity: 0;
}
.stability-energy-track__canvas--ready {
opacity: 1;
}
</style>
+91 -21
View File
@@ -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";
@@ -48,6 +49,7 @@ const { user } = storeToRefs(store);
const sound = ref(true); const sound = ref(true);
const start = ref(false); const start = ref(false);
const practiceStarting = ref(false); const practiceStarting = ref(false);
const stabilityEnergyIntroPending = ref(false);
const pageStages = Object.freeze({ const pageStages = Object.freeze({
DISTANCE: "distance", DISTANCE: "distance",
SHOOTING: "shooting", SHOOTING: "shooting",
@@ -105,14 +107,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("");
@@ -133,11 +135,14 @@ let shotPresentationGeneration = 0;
let nextDifficultyRequest = null; let nextDifficultyRequest = null;
let nextDifficultyRequestGeneration = 0; let nextDifficultyRequestGeneration = 0;
const audioWaiters = new Set(); const audioWaiters = new Set();
let stabilityEnergyIntroGeneration = 0;
let stabilityEnergyIntroTimer = null;
const shotEffectWaiters = new Map(); const shotEffectWaiters = new Map();
const PRACTICE_SYNC_TIMEOUT_MS = 5000; const PRACTICE_SYNC_TIMEOUT_MS = 5000;
const SHOT_EFFECT_WAIT_TIMEOUT_MS = 1200; const SHOT_EFFECT_WAIT_TIMEOUT_MS = 1200;
const AUDIO_TIMEOUT_BASE = 3500; const AUDIO_TIMEOUT_BASE = 3500;
const AUDIO_TIMEOUT_PER_KEY = 2600; const AUDIO_TIMEOUT_PER_KEY = 2600;
const STABILITY_ENERGY_ENTRY_DELAY_MS = 1000;
const AUDIO_TIMEOUT_MAX = 12000; const AUDIO_TIMEOUT_MAX = 12000;
const RUNTIME_DIAGNOSTIC_INTERVAL_MS = 60000; const RUNTIME_DIAGNOSTIC_INTERVAL_MS = 60000;
let lastRuntimeDiagnosticAt = 0; let lastRuntimeDiagnosticAt = 0;
@@ -195,9 +200,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 +364,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 +412,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 +582,7 @@ const practiceInfoFields = [
"blocks", "blocks",
"randomBlock", "randomBlock",
"randomRingArea", "randomRingArea",
"randomRingAreas",
"roundTime", "roundTime",
"shootTime", "shootTime",
"shootWindowStart", "shootWindowStart",
@@ -746,6 +774,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;
@@ -836,6 +867,12 @@ const playAudioKeysAndWait = (keys) => {
}); });
}; };
const clearStabilityEnergyIntroTimer = () => {
if (!stabilityEnergyIntroTimer) return;
clearTimeout(stabilityEnergyIntroTimer);
stabilityEnergyIntroTimer = null;
};
const shouldWaitForShotEffect = (shot) => { const shouldWaitForShotEffect = (shot) => {
const x = Number(shot?.x); const x = Number(shot?.x);
const y = Number(shot?.y); const y = Number(shot?.y);
@@ -1290,7 +1327,7 @@ const clearHighlightTestTimer = () => {
} }
}; };
// 开发环境测试入口:依次切换 8 个顺时针区域,偶数区域只高亮指定环。 // 开发环境测试入口:依次切换 8 个顺时针区域,偶数区域同时高亮两个环。
const runHighlightTest = () => { const runHighlightTest = () => {
clearHighlightTestTimer(); clearHighlightTestTimer();
useHighlightTest.value = true; useHighlightTest.value = true;
@@ -1302,7 +1339,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 +1352,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 +1366,7 @@ const resetHighlightTest = () => {
highlightTestState.value = { highlightTestState.value = {
blocks: 8, blocks: 8,
randomBlock: 1, randomBlock: 1,
randomRingArea: 0, randomRingAreas: [],
}; };
scores.value = []; scores.value = [];
}; };
@@ -1375,6 +1415,9 @@ onLoad((options = {}) => {
toRouteNumber(trainingContext.scoreSlot) toRouteNumber(trainingContext.scoreSlot)
), ),
}; };
// 稳定训练在测距阶段预挂载 Canvas,并提前绘制 100% 满能量首帧。
stabilityEnergyIntroPending.value =
trainingParams.value.type === "stability";
practiseId.value = trainingContext.practiceId || ""; practiseId.value = trainingContext.practiceId || "";
serverAddr.value = trainingContext.serverAddr || ""; serverAddr.value = trainingContext.serverAddr || "";
@@ -1420,15 +1463,30 @@ const onReady = async () => {
scores.value = []; scores.value = [];
shotEffectToken.value = 0; shotEffectToken.value = 0;
initializeRhythmFirstRoundCountdown(); initializeRhythmFirstRoundCountdown();
const shouldPlayStabilityEnergyIntro =
trainingType.value === "stability";
clearStabilityEnergyIntroTimer();
const stabilityIntroGeneration = ++stabilityEnergyIntroGeneration;
stabilityEnergyIntroPending.value = shouldPlayStabilityEnergyIntro;
const startAudioKey = getTrainingStartAudioKey(
trainingType.value,
"练习开始"
);
start.value = true; start.value = true;
pageStage.value = pageStages.SHOOTING; pageStage.value = pageStages.SHOOTING;
setPracticeAppHideResumable(true); setPracticeAppHideResumable(true);
// 先由本地锚点保证首轮立即显示,再用最新服务端快照无感校准。 // 先由本地锚点保证首轮立即显示,再用最新服务端快照无感校准。
requestPracticeInfoSync(); requestPracticeInfoSync();
// 开始接口成功即进入正式训练,直接播放对应提示,避免依赖 BattleStart 消息 // 开始语音只进入原播放队列,不参与能量条的入场计时
audioManager.play( audioManager.play(startAudioKey);
getTrainingStartAudioKey(trainingType.value, "练习开始") 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) { } catch (error) {
start.value = false; start.value = false;
pageStage.value = pageStages.DISTANCE; pageStage.value = pageStages.DISTANCE;
@@ -1619,6 +1677,9 @@ function onComplete() {
async function onRetry() { async function onRetry() {
pageStage.value = pageStages.LOADING; pageStage.value = pageStages.LOADING;
clearStabilityEnergyIntroTimer();
stabilityEnergyIntroGeneration += 1;
stabilityEnergyIntroPending.value = isStabilityTraining.value;
setPracticeAppHideResumable(false); setPracticeAppHideResumable(false);
clearHighlightTestTimer(); clearHighlightTestTimer();
useHighlightTest.value = false; useHighlightTest.value = false;
@@ -1731,6 +1792,9 @@ onMounted(() => {
}); });
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearStabilityEnergyIntroTimer();
stabilityEnergyIntroGeneration += 1;
stabilityEnergyIntroPending.value = false;
setPracticeAppHideResumable(false); setPracticeAppHideResumable(false);
invalidateShotPresentations(); invalidateShotPresentations();
clearPracticeRuntimeContext(); clearPracticeRuntimeContext();
@@ -1790,8 +1854,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 +1882,16 @@ 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"
:introPending="stabilityEnergyIntroPending"
/>
<!-- <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 +2087,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: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

+1 -1
View File
File diff suppressed because one or more lines are too long
+40 -3
View File
@@ -2,13 +2,23 @@ import protobuf from "protobufjs/minimal.js";
const { Reader, Writer } = protobuf; const { Reader, Writer } = protobuf;
// protobuf 会将这些 repeated 标量默认编码为 packedlength-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.jssha256: ec0371baeba9a9bf // 来源:src/utils/match.min.jssha256: 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) {
+5 -16
View File
@@ -87,28 +87,17 @@ function createWebSocket(token, onMessage) {
const { data, event, code, timestamp } = response || {}; const { data, event, code, timestamp } = response || {};
if (event === "pong") return; if (event === "pong") return;
const passthroughEvents = [ if (!data || typeof data !== "object") return;
"/addons/shoot/battery", if (data.type) {
"/addons/shoot/otaProgress",
"/addons/shoot/otaResult",
];
if (passthroughEvents.includes(event)) {
if ((code == null || Number(code) === 0) && onMessage && data && typeof data === "object") {
onMessage({ event, data, code, timestamp });
}
return;
}
if (data?.type) {
if (ENABLE_REALTIME_MESSAGE_LOG) { if (ENABLE_REALTIME_MESSAGE_LOG) {
console.log("收到 WebSocket 消息", getMessageTypeName(data.type)); console.log("收到 WebSocket 消息", getMessageTypeName(data.type));
} }
if (onMessage) onMessage({ ...(data.data || {}), type: data.type }); if (onMessage) onMessage({ ...(data.data || {}), type: data.type });
return; return;
} }
const updates = Array.isArray(data?.updates) ? data.updates : []; if (!Array.isArray(data.updates)) return;
if (!updates.length) return; if (onMessage) onMessage(data.updates);
if (onMessage) onMessage(updates); const msg = data.updates[0];
const msg = updates[0];
if (msg) { if (msg) {
if (ENABLE_REALTIME_MESSAGE_LOG) { if (ENABLE_REALTIME_MESSAGE_LOG) {
console.log( console.log(