update:优化精准稳定

This commit is contained in:
2026-09-22 15:15:16 +08:00
parent d4d690cb8e
commit 5357f131de
11 changed files with 797 additions and 112 deletions
+5 -5
View File
@@ -84,9 +84,9 @@ const props = defineProps({
type: Number,
default: 0,
},
activeRing: {
type: Number,
default: 0,
activeRings: {
type: Array,
default: () => [],
},
showSectorLabels: {
type: Boolean,
@@ -548,7 +548,7 @@ onBeforeUnmount(() => {
:highlightOnly="true"
:sectorCount="sectorCount"
:activeSector="activeSector"
:activeRing="activeRing"
:activeRings="activeRings"
:highlightRefreshToken="highlightRefreshToken"
:showSectorLabels="showSectorLabels"
/>
@@ -877,7 +877,7 @@ onBeforeUnmount(() => {
width: calc(100% - 20px);
padding: 0 10px;
display: flex;
margin-top: -40px;
margin-top: 15px;
justify-content: flex-end;
}
.footer > image {
+23 -53
View File
@@ -73,11 +73,7 @@ const props = defineProps({
type: Number,
default: 0,
},
energyPercent: {
type: Number,
default: 0,
},
energyReqPercent: {
energyCostPerSec: {
type: Number,
default: 0,
},
@@ -187,17 +183,10 @@ const progressPercent = computed(() => {
return Math.max(0, Math.min(100, (remain.value / props.total) * 100));
});
const stabilityEnergyPercent = computed(() =>
Math.max(0, Math.min(100, Number(props.energyPercent) || 0))
);
const stabilityEnergyReqPercent = computed(() =>
Math.max(0, Math.min(100, Number(props.energyReqPercent) || 0))
);
const stabilityEnergyReached = computed(
() =>
stabilityEnergyReqPercent.value > 0 &&
stabilityEnergyPercent.value >= stabilityEnergyReqPercent.value
);
const stabilityEnergyCostText = computed(() => {
const value = Math.max(0, Number(props.energyCostPerSec) || 0);
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(2)));
});
const rhythmMarkerPercent = computed(() => {
if (!validRhythmRoundTime.value) return 0;
@@ -568,22 +557,18 @@ onBeforeUnmount(() => {
"
>
<template v-if="isStabilityTraining">
<text class="stability-progress__time">{{ remain }}秒</text>
<view class="stability-progress__track">
<text class="stability-progress__cost">
每秒失去{{ stabilityEnergyCostText }}点能量
</text>
<view v-if="countdownEnabled" class="stability-progress__countdown">
<view
class="stability-progress__fill"
:class="{
'stability-progress__fill--reached': stabilityEnergyReached,
class="stability-progress__countdown-fill"
:style="{
width: `${progressPercent}%`,
transition: transitionStyle,
}"
:style="{ width: `${stabilityEnergyPercent}%` }"
/>
<view
class="stability-progress__marker"
:style="{ left: `${stabilityEnergyReqPercent}%` }"
/>
<text class="stability-progress__value">
{{ Math.round(stabilityEnergyPercent) }}%
</text>
<text class="stability-progress__countdown-text">剩余{{ remain }}秒</text>
</view>
</template>
<template v-else-if="isRhythmTraining">
@@ -811,17 +796,17 @@ onBeforeUnmount(() => {
margin: 32rpx 84rpx 0;
}
.stability-progress__time {
.stability-progress__cost {
display: block;
margin-bottom: 20rpx;
color: #ffffff;
font-size: 34rpx;
font-size: 32rpx;
font-weight: 500;
line-height: 48rpx;
line-height: 44rpx;
text-align: center;
}
.stability-progress__track {
.stability-progress__countdown {
position: relative;
width: 100%;
height: 24rpx;
@@ -830,38 +815,23 @@ onBeforeUnmount(() => {
background: #444444;
}
.stability-progress__fill {
.stability-progress__countdown-fill {
position: absolute;
top: 0;
bottom: 0;
left: 0;
border-radius: 18rpx;
background: linear-gradient(90deg, #87f1df 0%, #5ba8e8 100%);
transition: width 240ms linear, background 240ms ease;
background: linear-gradient(133deg, #ffd19a 0%, #a17636 100%);
}
.stability-progress__fill--reached {
background: linear-gradient(90deg, #a5df62 0%, #61c787 100%);
}
.stability-progress__marker {
position: absolute;
top: 0;
bottom: 0;
z-index: 2;
width: 4rpx;
transform: translateX(-2rpx);
background: rgba(26, 24, 22, 0.92);
}
.stability-progress__value {
.stability-progress__countdown-text {
position: absolute;
inset: 0;
z-index: 3;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
color: #eefaff;
color: #fff7de;
font-size: 18rpx;
line-height: 24rpx;
text-align: center;
@@ -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>