update:优化稳定训练

This commit is contained in:
2026-09-23 10:35:11 +08:00
parent 9de435a369
commit 9ad2c5e12e
6 changed files with 162 additions and 49 deletions
@@ -14,6 +14,10 @@ const props = defineProps({
type: Number,
default: 0,
},
introPending: {
type: Boolean,
default: false,
},
});
const WIDTH = 750;
@@ -33,30 +37,28 @@ 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 ASSET_VERSION = "20260922";
const getAssetSource = (filename) =>
`${ASSET_ROOT}/${filename}?v=${ASSET_VERSION}`;
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))
);
const shouldPlayEntryAnimation = ref(normalizedPercent.value <= 0);
const fallbackImageSource = computed(
() =>
getAssetSource(
shouldPlayEntryAnimation.value ? "progress-full.png" : "progress-empty.png"
)
);
let engine = null;
let disposed = false;
let animationHandle = null;
let renderedPercent = normalizedPercent.value;
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") {
@@ -81,6 +83,27 @@ const cancelFrame = () => {
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);
@@ -185,14 +208,11 @@ const loadCanvasImage = (canvas, source) =>
image.src = source;
});
const createEnergyEngine = async (canvas) => {
const createEnergyEngine = async (canvas, preloadedFullImage = null) => {
const context = canvas.getContext("2d");
if (!context) throw new Error("Canvas unavailable");
enableImageSmoothing(context);
canvas.width = WIDTH;
canvas.height = HEIGHT;
const maskLayer = createOffscreenCanvas(canvas, MASK_WIDTH, MASK_HEIGHT);
const emptyLayer = createOffscreenCanvas(canvas);
const fullLayer = createOffscreenCanvas(canvas);
@@ -208,6 +228,7 @@ const createEnergyEngine = async (canvas) => {
const images = await Promise.all([
loadCanvasImage(canvas, getAssetSource("progress-empty.png")),
preloadedFullImage ||
loadCanvasImage(canvas, getAssetSource("progress-full.png")),
loadCanvasImage(canvas, getAssetSource("progress-glow.png")),
]);
@@ -514,10 +535,11 @@ const drawPercent = (targetPercent, animate = true) => {
const runEntryAnimation = () => {
if (!engine || disposed) return;
cancelFrame();
entryAnimationRequested = false;
entryAnimationPlaying = true;
renderedPercent = 100;
engine.draw(100);
canvasReady.value = true;
showCanvas();
let startedAt = null;
const step = (timestamp) => {
@@ -537,7 +559,6 @@ const runEntryAnimation = () => {
animationHandle = null;
entryAnimationPlaying = false;
shouldPlayEntryAnimation.value = false;
renderedPercent = 0;
engine.draw(0);
@@ -565,36 +586,82 @@ const initialize = async () => {
if (disposed) return;
if (!info?.node?.getContext) {
shouldPlayEntryAnimation.value = false;
return;
}
try {
engine = await createEnergyEngine(info.node);
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;
if (shouldPlayEntryAnimation.value) {
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);
canvasReady.value = true;
await presentCanvas();
} 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;
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(() => {
@@ -607,20 +674,18 @@ onBeforeUnmount(() => {
<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"
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"
:class="[
'stability-energy-track__canvas',
canvasReady ? 'stability-energy-track__canvas--ready' : '',
]"
type="2d"
:width="WIDTH"
:height="HEIGHT"
@@ -636,6 +701,7 @@ onBeforeUnmount(() => {
height: 228rpx;
margin: -164rpx 0 -80rpx;
margin-left: -10rpx;
overflow: hidden;
pointer-events: none;
}
@@ -648,7 +714,17 @@ onBeforeUnmount(() => {
height: 100%;
}
.stability-energy-track__image--full {
transform: translateY(-2.8rpx);
.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>
+39 -4
View File
@@ -49,6 +49,7 @@ const { user } = storeToRefs(store);
const sound = ref(true);
const start = ref(false);
const practiceStarting = ref(false);
const stabilityEnergyIntroPending = ref(false);
const pageStages = Object.freeze({
DISTANCE: "distance",
SHOOTING: "shooting",
@@ -134,11 +135,14 @@ let shotPresentationGeneration = 0;
let nextDifficultyRequest = null;
let nextDifficultyRequestGeneration = 0;
const audioWaiters = new Set();
let stabilityEnergyIntroGeneration = 0;
let stabilityEnergyIntroTimer = null;
const shotEffectWaiters = new Map();
const PRACTICE_SYNC_TIMEOUT_MS = 5000;
const SHOT_EFFECT_WAIT_TIMEOUT_MS = 1200;
const AUDIO_TIMEOUT_BASE = 3500;
const AUDIO_TIMEOUT_PER_KEY = 2600;
const STABILITY_ENERGY_ENTRY_DELAY_MS = 1000;
const AUDIO_TIMEOUT_MAX = 12000;
const RUNTIME_DIAGNOSTIC_INTERVAL_MS = 60000;
let lastRuntimeDiagnosticAt = 0;
@@ -863,6 +867,12 @@ const playAudioKeysAndWait = (keys) => {
});
};
const clearStabilityEnergyIntroTimer = () => {
if (!stabilityEnergyIntroTimer) return;
clearTimeout(stabilityEnergyIntroTimer);
stabilityEnergyIntroTimer = null;
};
const shouldWaitForShotEffect = (shot) => {
const x = Number(shot?.x);
const y = Number(shot?.y);
@@ -1405,6 +1415,9 @@ onLoad((options = {}) => {
toRouteNumber(trainingContext.scoreSlot)
),
};
// 稳定训练在测距阶段预挂载 Canvas,并提前绘制 100% 满能量首帧。
stabilityEnergyIntroPending.value =
trainingParams.value.type === "stability";
practiseId.value = trainingContext.practiceId || "";
serverAddr.value = trainingContext.serverAddr || "";
@@ -1450,15 +1463,30 @@ const onReady = async () => {
scores.value = [];
shotEffectToken.value = 0;
initializeRhythmFirstRoundCountdown();
const shouldPlayStabilityEnergyIntro =
trainingType.value === "stability";
clearStabilityEnergyIntroTimer();
const stabilityIntroGeneration = ++stabilityEnergyIntroGeneration;
stabilityEnergyIntroPending.value = shouldPlayStabilityEnergyIntro;
const startAudioKey = getTrainingStartAudioKey(
trainingType.value,
"练习开始"
);
start.value = true;
pageStage.value = pageStages.SHOOTING;
setPracticeAppHideResumable(true);
// 先由本地锚点保证首轮立即显示,再用最新服务端快照无感校准。
requestPracticeInfoSync();
// 开始接口成功即进入正式训练,直接播放对应提示,避免依赖 BattleStart 消息
audioManager.play(
getTrainingStartAudioKey(trainingType.value, "练习开始")
);
// 开始语音只进入原播放队列,不参与能量条的入场计时
audioManager.play(startAudioKey);
if (shouldPlayStabilityEnergyIntro) {
stabilityEnergyIntroTimer = setTimeout(() => {
stabilityEnergyIntroTimer = null;
if (stabilityIntroGeneration !== stabilityEnergyIntroGeneration) return;
if (!isShootingStage.value || !isStabilityTraining.value) return;
stabilityEnergyIntroPending.value = false;
}, STABILITY_ENERGY_ENTRY_DELAY_MS);
}
} catch (error) {
start.value = false;
pageStage.value = pageStages.DISTANCE;
@@ -1649,6 +1677,9 @@ function onComplete() {
async function onRetry() {
pageStage.value = pageStages.LOADING;
clearStabilityEnergyIntroTimer();
stabilityEnergyIntroGeneration += 1;
stabilityEnergyIntroPending.value = isStabilityTraining.value;
setPracticeAppHideResumable(false);
clearHighlightTestTimer();
useHighlightTest.value = false;
@@ -1761,6 +1792,9 @@ onMounted(() => {
});
onBeforeUnmount(() => {
clearStabilityEnergyIntroTimer();
stabilityEnergyIntroGeneration += 1;
stabilityEnergyIntroPending.value = false;
setPracticeAppHideResumable(false);
invalidateShotPresentations();
clearPracticeRuntimeContext();
@@ -1856,6 +1890,7 @@ onBeforeUnmount(() => {
<StabilityEnergyTrack
v-if="isStabilityTraining"
:percent="stabilityEnergyPercent"
:introPending="stabilityEnergyIntroPending"
/>
<!-- <view v-if="env !== 'release'" class="highlight-test-actions">
<button

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

+3 -1
View File
@@ -79,6 +79,7 @@ function createWebSocket(token, onMessage) {
const { data, event } = JSON.parse(res.data);
if (event === "pong") return;
if (!data || typeof data !== "object") return;
if (data.type) {
if (ENABLE_REALTIME_MESSAGE_LOG) {
console.log("收到 WebSocket 消息", getMessageTypeName(data.type));
@@ -86,7 +87,8 @@ function createWebSocket(token, onMessage) {
if (onMessage) onMessage({ ...(data.data || {}), type: data.type });
return;
}
if (onMessage && data.updates) onMessage(data.updates);
if (!Array.isArray(data.updates)) return;
if (onMessage) onMessage(data.updates);
const msg = data.updates[0];
if (msg) {
if (ENABLE_REALTIME_MESSAGE_LOG) {