diff --git a/src/pages/training/components/StabilityEnergyTrack.vue b/src/pages/training/components/StabilityEnergyTrack.vue index 6419b13..15f6c9a 100644 --- a/src/pages/training/components/StabilityEnergyTrack.vue +++ b/src/pages/training/components/StabilityEnergyTrack.vue @@ -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(() => {