731 lines
21 KiB
Vue
731 lines
21 KiB
Vue
<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>
|