update:优化稳定训练
This commit is contained in:
@@ -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,7 +228,8 @@ const createEnergyEngine = async (canvas) => {
|
||||
|
||||
const images = await Promise.all([
|
||||
loadCanvasImage(canvas, getAssetSource("progress-empty.png")),
|
||||
loadCanvasImage(canvas, getAssetSource("progress-full.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;
|
||||
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 (shouldPlayEntryAnimation.value) {
|
||||
runEntryAnimation();
|
||||
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>
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user