独立渲染引擎就绪引擎就绪
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>负压吸附履带攀爬机器人 · IFR原理动画</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Rajdhani:wght@400;600;700&family=Noto+Sans+SC:wght@300;400;500;700&display=swap" rel="stylesheet">
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#060b18;--fg:#d8e4ff;--muted:#4a5580;--accent:#00e5ff;
--accent2:#ff7b3a;--accent3:#39ff14;--card:rgba(10,18,40,0.92);
--border:#1e2d55;--suction:#00bcd4;--danger:#ff2d55;
}
html,body{width:100%;height:100%;overflow:hidden;background:var(--bg);color:var(--fg);font-family:'Noto Sans SC',sans-serif}
body{display:flex;flex-direction:column;align-items:center;justify-content:center}
.canvas-wrap{position:relative;width:100vw;height:100vh;display:flex;align-items:center;justify-content:center}
canvas{display:block;width:100%;height:100%}
header{position:absolute;top:20px;left:32px;z-index:10;pointer-events:none}
header h1{font-family:'Rajdhani',sans-serif;font-size:clamp(22px,2.6vw,38px);font-weight:700;letter-spacing:2px;color:var(--accent);text-shadow:0 0 24px rgba(0,229,255,0.35)}
header p{font-size:clamp(12px,1.2vw,16px);color:var(--muted);margin-top:4px;font-weight:300;letter-spacing:1px}
.controls{position:absolute;bottom:24px;left:50%;transform:translateX(-50%);z-index:10;display:flex;gap:28px;align-items:center;
background:var(--card);border:1px solid var(--border);border-radius:14px;padding:14px 32px;backdrop-filter:blur(12px)}
.ctrl-group{display:flex;flex-direction:column;align-items:center;gap:6px}
.ctrl-group label{font-size:12px;color:var(--muted);letter-spacing:1px;font-weight:500}
.ctrl-group .val{font-family:'Rajdhani',sans-serif;font-size:16px;font-weight:700;color:var(--accent);min-width:60px;text-align:center}
input[type=range]{-webkit-appearance:none;appearance:none;width:140px;height:6px;border-radius:3px;background:var(--border);outline:none;cursor:pointer}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:16px;height:16px;border-radius:50%;background:var(--accent);box-shadow:0 0 8px rgba(0,229,255,0.5);cursor:pointer}
.btn{padding:8px 18px;border:1px solid var(--border);border-radius:8px;background:transparent;color:var(--fg);
font-family:'Noto Sans SC',sans-serif;font-size:13px;cursor:pointer;transition:all .2s;letter-spacing:1px}
.btn:hover{background:rgba(0,229,255,0.1);border-color:var(--accent);color:var(--accent)}
.btn.active{background:rgba(0,229,255,0.15);border-color:var(--accent);color:var(--accent)}
.legend{position:absolute;top:20px;right:24px;z-index:10;display:flex;flex-direction:column;gap:8px;
background:var(--card);border:1px solid var(--border);border-radius:12px;padding:14px 18px;backdrop-filter:blur(12px)}
.legend-item{display:flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}
.legend-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
@media(max-width:768px){
.controls{flex-wrap:wrap;gap:14px;padding:10px 18px}
input[type=range]{width:100px}
.legend{display:none}
}
</style>
</head>
<body>
<div class="canvas-wrap">
<canvas id="c"></canvas>
</div>
<header>
<h1>VACUUM-TRACK CLIMBER</h1>
<p>最终理想解原理演示 — 吸尘场复用:一源双效</p>
</header>
<div class="legend">
<div class="legend-item"><div class="legend-dot" style="background:#00e5ff;box-shadow:0 0 6px #00e5ff"></div>负压吸附区</div>
<div class="legend-item"><div class="legend-dot" style="background:#ff7b3a;box-shadow:0 0 6px #ff7b3a"></div>灰尘颗粒</div>
<div class="legend-item"><div class="legend-dot" style="background:#39ff14;box-shadow:0 0 6px #39ff14"></div>阀门开启</div>
<div class="legend-item"><div class="legend-dot" style="background:#ff2d55;box-shadow:0 0 6px #ff2d55"></div>阀门关闭</div>
</div>
<div class="controls">
<div class="ctrl-group">
<label>负压强度</label>
<input type="range" id="pressureSlider" min="1" max="10" value="5" step="0.5">
<div class="val" id="pressureVal">-5.0 kPa</div>
</div>
<div class="ctrl-group">
<label>动画速度</label>
<input type="range" id="speedSlider" min="1" max="10" value="5" step="0.5">
<div class="val" id="speedVal">1.0x</div>
</div>
<button class="btn" id="toggleIFR">IFR 原理</button>
<button class="btn" id="toggleDetail">微孔详图</button>
</div>
<script>
/* ============ 配置常量 ============ */
const CFG = {
stairTread: 170, // 台阶踏面宽度
stairRiser: 130, // 台阶立面高度
stairCount: 7, // 台阶数量(多出可见区域用于滚动)
bodyW: 240, // 机器人本体宽
bodyH: 72, // 机器人本体高
trackThick: 20, // 履带厚度
perfRadius: 2.2, // 微孔半径
perfSpacing: 14, // 微孔间距
valveW: 6, // 阀门宽
valveH: 10, // 阀门高
motorR: 22, // 电机半径
dustBoxW: 50, // 集尘盒宽
dustBoxH: 36, // 集尘盒高
maxParticles: 80, // 最大粒子数
colors: {
stair: '#1a2040',
stairEdge: '#2e3d65',
stairSurface: '#253058',
body: '#0c1428',
bodyStroke: '#2e4070',
track: '#141e38',
trackStroke: '#2a3e68',
cavity: '#0a1020',
suction: '#00e5ff',
suctionGlow: 'rgba(0,229,255,0.25)',
dust: '#ff7b3a',
valveOpen: '#39ff14',
valveClosed: '#ff2d55',
motor: '#1e2e50',
motorBlade: '#00e5ff',
label: '#8090b8',
ifrBg: 'rgba(10,16,32,0.94)',
ifrBorder: '#1e2d55'
}
};
/* ============ 状态变量 ============ */
let canvas, ctx, W, H, dpr;
let time = 0;
let pressure = 5;
let speed = 1;
let scrollOffset = 0;
let showIFR = true;
let showDetail = true;
let particles = [];
let stairProfilePoints = [];
/* ============ 初始化 ============ */
function init() {
canvas = document.getElementById('c');
ctx = canvas.getContext('2d');
resize();
window.addEventListener('resize', resize);
// 控件绑定
const ps = document.getElementById('pressureSlider');
const ss = document.getElementById('speedSlider');
ps.addEventListener('input', () => {
pressure = parseFloat(ps.value);
document.getElementById('pressureVal').textContent = `-${pressure.toFixed(1)} kPa`;
});
ss.addEventListener('input', () => {
speed = parseFloat(ss.value) / 5;
document.getElementById('speedVal').textContent = `${speed.toFixed(1)}x`;
});
document.getElementById('toggleIFR').addEventListener('click', function() {
showIFR = !showIFR;
this.classList.toggle('active', showIFR);
});
document.getElementById('toggleDetail').addEventListener('click', function() {
showDetail = !showDetail;
this.classList.toggle('active', showDetail);
});
document.getElementById('toggleIFR').classList.add('active');
document.getElementById('toggleDetail').classList.add('active');
// 初始化粒子
initParticles();
// 启动动画
requestAnimationFrame(loop);
}
function resize() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
W = window.innerWidth;
H = window.innerHeight;
canvas.width = W * dpr;
canvas.height = H * dpr;
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// 生成楼梯轮廓
buildStairProfile();
}
/* ============ 楼梯轮廓生成 ============ */
function buildStairProfile() {
stairProfilePoints = [];
const cx = W * 0.38; // 楼梯中心X偏移
const by = H * 0.78; // 底部Y
for (let i = 0; i < CFG.stairCount; i++) {
const x = cx + i * CFG.stairTread;
const y = by - i * CFG.stairRiser;
stairProfilePoints.push({ x, y, type: 'tread' });
const rx = x + CFG.stairTread;
const ry = y;
stairProfilePoints.push({ x: rx, y: ry, type: 'riser-top' });
}
}
/* ============ 粒子系统 ============ */
function initParticles() {
particles = [];
for (let i = 0; i < CFG.maxParticles; i++) {
particles.push(createParticle());
}
}
function createParticle() {
return {
x: 0, y: 0,
vx: 0, vy: 0,
life: 0,
maxLife: 60 + Math.random() * 80,
size: 1.5 + Math.random() * 2,
phase: Math.random() * Math.PI * 2,
active: false
};
}
function spawnDustParticle(p, contactX, contactY, targetX, targetY) {
p.x = contactX + (Math.random() - 0.5) * 20;
p.y = contactY + (Math.random() - 0.5) * 6;
p.vx = (targetX - p.x) / p.maxLife;
p.vy = (targetY - p.y) / p.maxLife;
p.life = 0;
p.maxLife = 40 + Math.random() * 60;
p.active = true;
}
/* ============ 获取楼梯面上的点 ============ */
function getStairSurfacePoint(t) {
// t: 0~1 沿楼梯表面的参数
const totalLen = (CFG.stairCount - 1) * (CFG.stairTread + CFG.stairRiser);
const dist = t * totalLen;
const cx = W * 0.38;
const by = H * 0.78;
let accumulated = 0;
for (let i = 0; i < CFG.stairCount - 1; i++) {
const treadStart = cx + i * CFG.stairTread;
const treadY = by - i * CFG.stairRiser;
// 踏面段
if (accumulated + CFG.stairTread >= dist) {
const frac = (dist - accumulated) / CFG.stairTread;
return { x: treadStart + frac * CFG.stairTread, y: treadY, normal: { x: 0, y: -1 }, type: 'tread' };
}
accumulated += CFG.stairTread;
// 立面段
const riserX = treadStart + CFG.stairTread;
if (accumulated + CFG.stairRiser >= dist) {
const frac = (dist - accumulated) / CFG.stairRiser;
return { x: riserX, y: treadY - frac * CFG.stairRiser, normal: { x: 1, y: 0 }, type: 'riser' };
}
accumulated += CFG.stairRiser;
}
// 最后一级踏面
const lastI = CFG.stairCount - 1;
return { x: cx + lastI * CFG.stairTread, y: by - lastI * CFG.stairRiser, normal: { x: 0, y: -1 }, type: 'tread' };
}
/* ============ 绘制函数 ============ */
// 绘制背景网格
function drawBackground() {
ctx.fillStyle = '#060b18';
ctx.fillRect(0, 0, W, H);
// 网格
ctx.strokeStyle = 'rgba(0,229,255,0.03)';
ctx.lineWidth = 0.5;
const gridSize = 40;
for (let x = 0; x < W; x += gridSize) {
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, H); ctx.stroke();
}
for (let y = 0; y < H; y += gridSize) {
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(W, y); ctx.stroke();
}
// 氛围光
const g1 = ctx.createRadialGradient(W * 0.35, H * 0.45, 0, W * 0.35, H * 0.45, W * 0.5);
g1.addColorStop(0, 'rgba(0,229,255,0.04)');
g1.addColorStop(1, 'rgba(0,229,255,0)');
ctx.fillStyle = g1;
ctx.fillRect(0, 0, W, H);
}
// 绘制楼梯
function drawStairs() {
const cx = W * 0.38;
const by = H * 0.78;
const oy = scrollOffset;
// 楼梯主体多边形
ctx.beginPath();
ctx.moveTo(cx - 40, by - oy + 10);
for (let i = 0; i < CFG.stairCount; i++) {
const sx = cx + i * CFG.stairTread;
const sy = by - i * CFG.stairRiser - oy;
ctx.lineTo(sx, sy);
ctx.lineTo(sx + CFG.stairTread, sy);
}
// 闭合到底部右侧
const lastX = cx + (CFG.stairCount - 1) * CFG.stairTread + CFG.stairTread;
ctx.lineTo(lastX, by - oy + 10);
ctx.closePath();
const sg = ctx.createLinearGradient(0, by - 400, 0, by + 50);
sg.addColorStop(0, '#1a2548');
sg.addColorStop(1, '#0e1530');
ctx.fillStyle = sg;
ctx.fill();
// 楼梯边缘
ctx.strokeStyle = '#2e3d65';
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < CFG.stairCount; i++) {
const sx = cx + i * CFG.stairTread;
const sy = by - i * CFG.stairRiser - oy;
if (i === 0) ctx.moveTo(sx, sy);
ctx.lineTo(sx + CFG.stairTread, sy);
if (i < CFG.stairCount - 1) {
ctx.lineTo(sx + CFG.stairTread, sy - CFG.stairRiser);
}
}
ctx.stroke();
// 踏面高亮
ctx.strokeStyle = 'rgba(0,229,255,0.08)';
ctx.lineWidth = 1;
for (let i = 0; i < CFG.stairCount; i++) {
const sx = cx + i * CFG.stairTread;
const sy = by - i * CFG.stairRiser - oy;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx + CFG.stairTread, sy);
ctx.stroke();
}
// 表面纹理(细微点阵)
ctx.fillStyle = 'rgba(46,61,101,0.4)';
for (let i = 0; i < CFG.stairCount; i++) {
const sx = cx + i * CFG.stairTread;
const sy = by - i * CFG.stairRiser - oy;
for (let dx = 8; dx < CFG.stairTread; dx += 12) {
for (let dy = 3; dy < 8; dy += 5) {
ctx.fillRect(sx + dx, sy + dy, 1, 1);
}
}
// 立面纹理
if (i < CFG.stairCount - 1) {
for (let dy = 5; dy < CFG.stairRiser; dy += 10) {
for (let dx = 3; dx < 8; dx += 5) {
ctx.fillRect(sx + CFG.stairTread + dx, sy - dy, 1, 1);
}
}
}
}
}
// 获取机器人当前在楼梯上的位置
function getRobotOnStairs() {
const cx = W * 0.38;
const by = H * 0.78;
const oy = scrollOffset;
// 机器人中心位于第2-3级台阶上
const stepIdx = 2;
const stepX = cx + stepIdx * CFG.stairTread;
const stepY = by - stepIdx * CFG.stairRiser - oy;
return { cx: stepX + CFG.stairTread * 0.5, cy: stepY, stepX, stepY, stepIdx };
}
// 绘制机器人(剖面视图)
function drawRobot() {
const info = getRobotOnStairs();
const rcx = info.cx;
const rcy = info.cy;
const bw = CFG.bodyW;
const bh = CFG.bodyH;
const tt = CFG.trackThick;
const oy = scrollOffset;
const cx = W * 0.38;
const by = H * 0.78;
// 机器人角度(跟随楼梯倾斜)
const angle = Math.atan2(CFG.stairRiser, CFG.stairTread);
// 机器人本体位置(底部贴在台阶上)
const bodyLeft = rcx - bw * 0.55;
const bodyBottom = rcy - tt;
const bodyTop = bodyBottom - bh;
ctx.save();
// ---- 履带外轮廓(贴合楼梯表面) ----
// 履带底部路径跟随楼梯表面(踏面+立面+踏面)
const trackBottomPath = [];
const trackTopPath = [];
// 生成履带底部路径点(沿楼梯表面)
const stepI = info.stepIdx;
const s1x = cx + stepI * CFG.stairTread;
const s1y = by - stepI * CFG.stairRiser - oy;
const s2x = cx + (stepI + 1) * CFG.stairTread;
const s2y = by - (stepI + 1) * CFG.stairRiser - oy;
// 底部路径:沿踏面 → 上立面 → 沿上级踏面
trackBottomPath.push({ x: s1x - 30, y: s1y });
trackBottomPath.push({ x: s2x, y: s1y });
trackBottomPath.push({ x: s2x, y: s2y });
trackBottomPath.push({ x: s2x + CFG.stairTread + 30, y: s2y });
// 顶部路径:平滑弧线(履带绕过本体顶部)
const topY = s2y - bh - tt - 15;
const topLeftX = s1x - 30;
const topRightX = s2x + CFG.stairTread + 30;
trackTopPath.push({ x: topRightX, y: s2y - tt });
trackTopPath.push({ x: topRightX, y: topY + 20 });
trackTopPath.push({ x: topRightX - 20, y: topY });
trackTopPath.push({ x: topLeftX + 20, y: topY });
trackTopPath.push({ x: topLeftX, y: topY + 20 });
trackTopPath.push({ x: topLeftX, y: s1y - tt });
// 绘制履带外形
ctx.beginPath();
ctx.moveTo(trackBottomPath[0].x, trackBottomPath[0].y);
for (let i = 1; i < trackBottomPath.length; i++) {
ctx.lineTo(trackBottomPath[i].x, trackBottomPath[i].y);
}
for (let i = 0; i < trackTopPath.length; i++) {
ctx.lineTo(trackTopPath[i].x, trackTopPath[i].y);
}
ctx.closePath();
const tg = ctx.createLinearGradient(0, topY, 0, s1y);
tg.addColorStop(0, '#141e38');
tg.addColorStop(1, '#1a2848');
ctx.fillStyle = tg;
ctx.fill();
ctx.strokeStyle = '#2a3e68';
ctx.lineWidth = 1.5;
ctx.stroke();
// 履带内腔
const innerOffset = tt * 0.6;
ctx.beginPath();
ctx.moveTo(trackBottomPath[0].x, trackBottomPath[0].y - innerOffset);
for (let i = 1; i < trackBottomPath.length; i++) {
ctx.lineTo(trackBottomPath[i].x, trackBottomPath[i].y - innerOffset);
}
for (let i = trackTopPath.length - 1; i >= 0; i--) {
const dy = trackTopPath[i].y < topY + 40 ? innerOffset : -innerOffset;
ctx.lineTo(trackTopPath[i].x + (trackTopPath[i].x > rcx ? -innerOffset : innerOffset), trackTopPath[i].y + dy);
}
ctx.closePath();
ctx.fillStyle = '#080e1e';
ctx.fill();
// ---- 本体 ----
const bodyPath = [
{ x: s1x - 10, y: s1y - tt - innerOffset - 2 },
{ x: s2x + CFG.stairTread * 0.6, y: s2y - tt - innerOffset - 2 },
{ x: s2x + CFG.stairTread * 0.6, y: topY + 28 },
{ x: s1x - 10, y: topY + 35 }
];
ctx.beginPath();
ctx.moveTo(bodyPath[0].x, bodyPath[0].y);
for (let i = 1; i < bodyPath.length; i++) ctx.lineTo(bodyPath[i].x, bodyPath[i].y);
ctx.closePath();
const bg = ctx.createLinearGradient(0, topY, 0, s1y);
bg.addColorStop(0, '#0c1428');
bg.addColorStop(1, '#101c34');
ctx.fillStyle = bg;
ctx.fill();
ctx.strokeStyle = '#2e4070';
ctx.lineWidth = 1;
ctx.stroke();
// ---- 内部组件 ----
const bodyCx = (bodyPath[0].x + bodyPath[1].x) / 2;
const bodyCy = (bodyPath[0].y + bodyPath[2].y) / 2;
// 电机
const motorX = bodyCx - 30;
const motorY = bodyCy - 5;
ctx.beginPath();
ctx.arc(motorX, motorY, CFG.motorR, 0, Math.PI * 2);
ctx.fillStyle = '#0e1830';
ctx.fill();
ctx.strokeStyle = '#2e4070';
ctx.lineWidth = 1;
ctx.stroke();
// 电机风扇叶片(旋转动画)
const fanAngle = time * 4;
ctx.strokeStyle = CFG.colors.motorBlade;
ctx.lineWidth = 2;
ctx.globalAlpha = 0.7;
for (let i = 0; i < 5; i++) {
const a = fanAngle + i * Math.PI * 2 / 5;
ctx.beginPath();
ctx.moveTo(motorX, motorY);
ctx.lineTo(motorX + Math.cos(a) * CFG.motorR * 0.85, motorY + Math.sin(a) * CFG.motorR * 0.85);
ctx.stroke();
}
ctx.globalAlpha = 1;
// 电机标签
ctx.fillStyle = '#5a6a90';
ctx.font = '10px "Noto Sans SC"';
ctx.textAlign = 'center';
ctx.fillText('吸尘电机', motorX, motorY + CFG.motorR + 14);
// 集尘盒
const dbX = bodyCx + 20;
const dbY = bodyCy - CFG.dustBoxH / 2 - 5;
ctx.fillStyle = '#0e1830';
ctx.strokeStyle = '#2e4070';
ctx.lineWidth = 1;
roundRect(ctx, dbX, dbY, CFG.dustBoxW, CFG.dustBoxH, 4);
ctx.fill();
ctx.stroke();
// 集尘盒填充量(动态)
const fillH = CFG.dustBoxH * (0.3 + 0.2 * Math.sin(time * 0.5));
ctx.fillStyle = 'rgba(255,123,58,0.25)';
ctx.fillRect(dbX + 2, dbY + CFG.dustBoxH - fillH, CFG.dustBoxW - 4, fillH - 2);
ctx.fillStyle = '#5a6a90';
ctx.font = '10px "Noto Sans SC"';
ctx.textAlign = 'center';
ctx.fillText('集尘盒', dbX + CFG.dustBoxW / 2, dbY + CFG.dustBoxH + 14);
// 导管:从履带腔体到集尘盒
ctx.strokeStyle = 'rgba(0,229,255,0.15)';
ctx.lineWidth = 3;
ctx.setLineDash([4, 4]);
ctx.lineDashOffset = -time * 30;
ctx.beginPath();
ctx.moveTo(bodyCx - 60, bodyPath[0].y - 15);
ctx.quadraticCurveTo(bodyCx - 30, bodyCy + 10, dbX, dbY + CFG.dustBoxH / 2);
ctx.stroke();
ctx.setLineDash([]);
// ---- 履带微孔(动画滚动) ----
const perfOffset = (time * 40) % CFG.perfSpacing;
ctx.fillStyle = 'rgba(0,229,255,0.5)';
// 底部履带面的微孔
for (let i = 0; i < trackBottomPath.length - 1; i++) {
const p1 = trackBottomPath[i];
const p2 = trackBottomPath[i + 1];
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const len = Math.sqrt(dx * dx + dy * dy);
const nx = -dy / len; // 法线方向(朝外)
const ny = dx / len;
const count = Math.floor(len / CFG.perfSpacing);
for (let j = 0; j < count; j++) {
const t2 = ((j * CFG.perfSpacing + perfOffset) % len) / len;
const px = p1.x + dx * t2 + nx * tt * 0.35;
const py = p1.y + dy * t2 + ny * tt * 0.35;
// 判断是否在接触面附近(阀门开启)
const isContact = isNearStairSurface(px, py, oy);
ctx.beginPath();
ctx.arc(px, py, CFG.perfRadius, 0, Math.PI * 2);
ctx.fillStyle = isContact ? 'rgba(0,229,255,0.7)' : 'rgba(30,45,85,0.5)';
ctx.fill();
}
}
// 顶部履带面的微孔
for (let i = 0; i < trackTopPath.length - 1; i++) {
const p1 = trackTopPath[i];
const p2 = trackTopPath[i + 1];
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const len = Math.sqrt(dx * dx + dy * dy);
if (len < 1) continue;
const nx = dy / len;
const ny = -dx / len;
const count = Math.floor(len / CFG.perfSpacing);
for (let j = 0; j < count; j++) {
const t2 = ((j * CFG.perfSpacing + perfOffset) % len) / len;
const px = p1.x + dx * t2 + nx * tt * 0.35;
const py = p1.y + dy * t2 + ny * tt * 0.35;
ctx.beginPath();
ctx.arc(px, py, CFG.perfRadius, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(30,45,85,0.4)';
ctx.fill();
}
}
// ---- 吸附区光晕 ----
drawSuctionGlow(s1x, s1y, s2x, s2y, oy);
// ---- 阀门指示 ----
drawValves(s1x, s1y, s2x, s2y, oy, perfOffset);
ctx.restore();
}
// 判断点是否在楼梯表面附近
function isNearStairSurface(px, py, oy) {
const cx = W * 0.38;
const by = H * 0.78;
const margin = 8;
for (let i = 0; i < CFG.stairCount; i++) {
const sx = cx + i * CFG.stairTread;
const sy = by - i * CFG.stairRiser - oy;
// 踏面
if (px >= sx - margin && px <= sx + CFG.stairTread + margin && Math.abs(py - sy) < margin + 4) return true;
// 立面
if (i < CFG.stairCount - 1) {
const rx = sx + CFG.stairTread;
if (Math.abs(px - rx) < margin + 4 && py >= sy - CFG.stairRiser - margin && py <= sy + margin) return true;
}
}
return false;
}
// 绘制吸附区发光效果
function drawSuctionGlow(s1x, s1y, s2x, s2y, oy) {
const intensity = pressure / 10;
const pulse = 0.7 + 0.3 * Math.sin(time * 3);
const alpha = intensity * pulse;
// 下踏面吸附
const g1 = ctx.createLinearGradient(s1x - 30, s1y, s1x - 30, s1y - 25);
g1.addColorStop(0, `rgba(0,229,255,${0.35 * alpha})`);
g1.addColorStop(1, 'rgba(0,229,255,0)');
ctx.fillStyle = g1;
ctx.fillRect(s1x - 30, s1y - 25, (s2x - s1x + 30), 25);
// 立面吸附
const g2 = ctx.createLinearGradient(s2x, s1y, s2x + 25, s1y);
g2.addColorStop(0, `rgba(0,229,255,${0.35 * alpha})`);
g2.addColorStop(1, 'rgba(0,229,255,0)');
ctx.fillStyle = g2;
ctx.fillRect(s2x, s2y, 25, s1y - s2y);
// 上踏面吸附
const g3 = ctx.createLinearGradient(s2x, s2y, s2x, s2y - 25);
g3.addColorStop(0, `rgba(0,229,255,${0.35 * alpha})`);
g3.addColorStop(1, 'rgba(0,229,255,0)');
ctx.fillStyle = g3;
ctx.fillRect(s2x, s2y - 25, CFG.stairTread + 30, 25);
// 接触面发光线
ctx.strokeStyle = `rgba(0,229,255,${0.6 * alpha})`;
ctx.lineWidth = 2;
ctx.shadowColor = '#00e5ff';
ctx.shadowBlur = 12 * alpha;
// 踏面接触线
ctx.beginPath();
ctx.moveTo(s1x - 25, s1y);
ctx.lineTo(s2x, s1y);
ctx.stroke();
// 立面接触线
ctx.beginPath();
ctx.moveTo(s2x, s1y);
ctx.lineTo(s2x, s2y);
ctx.stroke();
// 上踏面接触线
ctx.beginPath();
ctx.moveTo(s2x, s2y);
ctx.lineTo(s2x + CFG.stairTread + 25, s2y);
ctx.stroke();
ctx.shadowBlur = 0;
// 吸附力箭头(指向楼梯面)
drawAdhesionArrows(s1x, s1y, s2x, s2y, alpha);
}
// 绘制吸附力箭头
function drawAdhesionArrows(s1x, s1y, s2x, s2y, alpha) {
ctx.strokeStyle = `rgba(0,229,255,${0.5 * alpha})`;
ctx.fillStyle = `rgba(0,229,255,${0.5 * alpha})`;
ctx.lineWidth = 1.5;
const arrowLen = 18 + 6 * Math.sin(time * 4);
// 下踏面向下的力
for (let i = 0; i < 3; i++) {
const ax = s1x + (s2x - s1x) * (0.2 + i * 0.3);
const ay = s1y - 30;
drawArrow(ax, ay, ax, ay + arrowLen, 4);
}
// 立面向右的力
for (let i = 0; i < 2; i++) {
const ax = s2x - 30;
const ay = s1y - (s1y - s2y) * (0.3 + i * 0.4);
drawArrow(ax, ay, ax + arrowLen, ay, 4);
}
// 上踏面向下的力
for (let i = 0; i < 3; i++) {
const ax = s2x + (CFG.stairTread + 20) * (0.15 + i * 0.3);
const ay = s2y - 30;
drawArrow(ax, ay, ax, ay + arrowLen, 4);
}
}
function drawArrow(x1, y1, x2, y2, headLen) {
const angle = Math.atan2(y2 - y1, x2 - x1);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.lineTo(x2 - headLen * Math.cos(angle - 0.4), y2 - headLen * Math.sin(angle - 0.4));
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - headLen * Math.cos(angle + 0.4), y2 - headLen * Math.sin(angle + 0.4));
ctx.stroke();
}
// 绘制阀门
function drawValves(s1x, s1y, s2x, s2y, oy, perfOffset) {
const cx = W * 0.38;
const by = H * 0.78;
// 在接触面区域画开启阀门,非接触区域画关闭阀门
const valvePositions = [
// 下踏面阀门(开启)
{ x: s1x + 30, y: s1y - CFG.trackThick * 0.7, open: true },
{ x: s1x + 80, y: s1y - CFG.trackThick * 0.7, open: true },
{ x: s2x - 20, y: s1y - CFG.trackThick * 0.7, open: true },
// 立面阀门(开启)
{ x: s2x + CFG.trackThick * 0.7, y: s1y - 30, open: true },
{ x: s2x + CFG.trackThick * 0.7, y: s2y + 30, open: true },
// 上踏面阀门(开启)
{ x: s2x + 40, y: s2y - CFG.trackThick * 0.7, open: true },
{ x: s2x + 100, y: s2y - CFG.trackThick * 0.7, open: true },
// 顶部阀门(关闭)
{ x: (s1x + s2x) / 2, y: s2y - 60, open: false },
{ x: (s1x + s2x) / 2 + 40, y: s2y - 60, open: false },
];
valvePositions.forEach(v => {
ctx.fillStyle = v.open ? CFG.colors.valveOpen : CFG.colors.valveClosed;
ctx.globalAlpha = v.open ? (0.6 + 0.3 * Math.sin(time * 5)) : 0.3;
if (v.open) {
ctx.shadowColor = CFG.colors.valveOpen;
ctx.shadowBlur = 6;
}
roundRect(ctx, v.x - CFG.valveW / 2, v.y - CFG.valveH / 2, CFG.valveW, CFG.valveH, 1.5);
ctx.fill();
ctx.shadowBlur = 0;
ctx.globalAlpha = 1;
});
}
// 绘制灰尘粒子
function drawParticles() {
const info = getRobotOnStairs();
const cx = W * 0.38;
const by = H * 0.78;
const oy = scrollOffset;
const stepI = info.stepIdx;
const s1x = cx + stepI * CFG.stairTread;
const s1y = by - stepI * CFG.stairRiser - oy;
const s2x = cx + (stepI + 1) * CFG.stairTread;
const s2y = by - (stepI + 1) * CFG.stairRiser - oy;
const intensity = pressure / 10;
particles.forEach((p, idx) => {
if (!p.active) {
// 在接触面附近生成新粒子
if (Math.random() < 0.06 * intensity * speed) {
const zone = Math.random();
let sx, sy, tx, ty;
if (zone < 0.4) {
// 下踏面
sx = s1x + Math.random() * (s2x - s1x);
sy = s1y + 2;
tx = info.cx - 30 + Math.random() * 20;
ty = info.cy - CFG.bodyH * 0.5;
} else if (zone < 0.7) {
// 立面
sx = s2x + 2;
sy = s2y + Math.random() * (s1y - s2y);
tx = info.cx + 10 + Math.random() * 20;
ty = info.cy - CFG.bodyH * 0.4;
} else {
// 上踏面
sx = s2x + Math.random() * CFG.stairTread;
sy = s2y + 2;
tx = info.cx + 20 + Math.random() * 20;
ty = s2y - CFG.bodyH * 0.6;
}
spawnDustParticle(p, sx, sy, tx, ty);
}
return;
}
p.life++;
if (p.life >= p.maxLife) {
p.active = false;
return;
}
const t2 = p.life / p.maxLife;
const ease = t2 * t2 * (3 - 2 * t2); // smoothstep
p.x += p.vx * speed;
p.y += p.vy * speed;
// 添加一些随机扰动
p.x += Math.sin(time * 8 + p.phase) * 0.3;
p.y += Math.cos(time * 6 + p.phase) * 0.2;
const alpha = t2 < 0.1 ? t2 / 0.1 : (t2 > 0.8 ? (1 - t2) / 0.2 : 1);
const size = p.size * (1 - t2 * 0.5);
ctx.beginPath();
ctx.arc(p.x, p.y, Math.max(0.5, size), 0, Math.PI * 2);
ctx.fillStyle = `rgba(255,123,58,${0.8 * alpha})`;
ctx.fill();
// 粒子尾迹
if (alpha > 0.3) {
ctx.beginPath();
ctx.arc(p.x - p.vx * 2, p.y - p.vy * 2, Math.max(0.3, size * 0.5), 0, Math.PI * 2);
ctx.fillStyle = `rgba(255,123,58,${0.3 * alpha})`;
ctx.fill();
}
});
}
// 绘制气流线
function drawAirflow() {
const info = getRobotOnStairs();
const cx = W * 0.38;
const by = H * 0.78;
const oy = scrollOffset;
const stepI = info.stepIdx;
const s1x = cx + stepI * CFG.stairTread;
const s1y = by - stepI * CFG.stairRiser - oy;
const s2x = cx + (stepI + 1) * CFG.stairTread;
const s2y = by - (stepI + 1) * CFG.stairRiser - oy;
const intensity = pressure / 10;
ctx.strokeStyle = `rgba(0,229,255,${0.12 * intensity})`;
ctx.lineWidth = 1.5;
ctx.setLineDash([6, 8]);
ctx.lineDashOffset = -time * 50 * speed;
// 从接触面向腔体内部的气流
const flowLines = [
// 下踏面 → 腔体
{ from: { x: s1x + 40, y: s1y - 5 }, to: { x: info.cx - 40, y: s1y - CFG.trackThick - 10 }, cp: { x: s1x + 20, y: s1y - CFG.trackThick } },
{ from: { x: s1x + 90, y: s1y - 5 }, to: { x: info.cx - 20, y: s1y - CFG.trackThick - 15 }, cp: { x: s1x + 70, y: s1y - CFG.trackThick - 5 } },
// 立面 → 腔体
{ from: { x: s2x + 5, y: (s1y + s2y) / 2 }, to: { x: info.cx, y: (s1y + s2y) / 2 - CFG.trackThick }, cp: { x: s2x + CFG.trackThick, y: (s1y + s2y) / 2 - 10 } },
// 上踏面 → 腔体
{ from: { x: s2x + 50, y: s2y - 5 }, to: { x: info.cx + 20, y: s2y - CFG.trackThick - 15 }, cp: { x: s2x + 40, y: s2y - CFG.trackThick - 5 } },
];
flowLines.forEach(fl => {
ctx.beginPath();
ctx.moveTo(fl.from.x, fl.from.y);
ctx.quadraticCurveTo(fl.cp.x, fl.cp.y, fl.to.x, fl.to.y);
ctx.stroke();
});
ctx.setLineDash([]);
}
// 绘制标注
function drawLabels() {
const info = getRobotOnStairs();
const cx = W * 0.38;
const by = H * 0.78;
const oy = scrollOffset;
const stepI = info.stepIdx;
const s1x = cx + stepI * CFG.stairTread;
const s1y = by - stepI * CFG.stairRiser - oy;
const s2x = cx + (stepI + 1) * CFG.stairTread;
const s2y = by - (stepI + 1) * CFG.stairRiser - oy;
ctx.font = '500 12px "Noto Sans SC"';
ctx.textAlign = 'left';
const labels = [
{ text: '微孔 Ø1.5mm', x: s1x - 100, y: s1y - 8, lx: s1x - 20, ly: s1y - 4 },
{ text: `腔体负压 -${pressure.toFixed(1)}kPa`, x: info.cx - 70, y: s1y - CFG.trackThick - 30, lx: info.cx, ly: s1y - CFG.trackThick - 10 },
{ text: '分区阀门(开)', x: s2x + CFG.trackThick + 10, y: (s1y + s2y) / 2 + 4, lx: s2x + CFG.trackThick, ly: (s1y + s2y) / 2 },
{ text: '柔性硅胶履带', x: s1x - 110, y: s1y + 25, lx: s1x - 15, ly: s1y + 5 },
];
labels.forEach(l => {
// 引线
ctx.strokeStyle = 'rgba(128,144,184,0.3)';
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(l.lx, l.ly);
ctx.lineTo(l.x + (l.lx > l.x ? 5 : -5), l.y - 4);
ctx.stroke();
// 小圆点
ctx.fillStyle = 'rgba(0,229,255,0.5)';
ctx.beginPath();
ctx.arc(l.lx, l.ly, 2.5, 0, Math.PI * 2);
ctx.fill();
// 文字
ctx.fillStyle = '#8090b8';
ctx.fillText(l.text, l.x, l.y);
});
}
// 绘制 IFR 原理图
function drawIFRPanel() {
if (!showIFR) return;
const px = W - 340;
const py = 70;
const pw = 310;
const ph = 200;
// 面板背景
ctx.fillStyle = CFG.colors.ifrBg;
roundRect(ctx, px, py, pw, ph, 10);
ctx.fill();
ctx.strokeStyle = CFG.colors.ifrBorder;
ctx.lineWidth = 1;
roundRect(ctx, px, py, pw, ph, 10);
ctx.stroke();
// 标题
ctx.fillStyle = '#00e5ff';
ctx.font = '700 14px "Rajdhani","Noto Sans SC"';
ctx.textAlign = 'center';
ctx.fillText('IFR · 最终理想解', px + pw / 2, py + 24);
// 中心:负压场
const ccx = px + pw / 2;
const ccy = py + 100;
const pulseR = 28 + 4 * Math.sin(time * 3);
// 发光圈
const g = ctx.createRadialGradient(ccx, ccy, 0, ccx, ccy, pulseR + 15);
g.addColorStop(0, 'rgba(0,229,255,0.3)');
g.addColorStop(0.6, 'rgba(0,229,255,0.08)');
g.addColorStop(1, 'rgba(0,229,255,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(ccx, ccy, pulseR + 15, 0, Math.PI * 2);
ctx.fill();
// 内圈
ctx.strokeStyle = '#00e5ff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(ccx, ccy, pulseR, 0, Math.PI * 2);
ctx.stroke();
ctx.fillStyle = '#00e5ff';
ctx.font = '700 13px "Noto Sans SC"';
ctx.fillText('负压场', ccx, ccy + 5);
// 左侧:吸附固定
const lx = px + 55;
const ly = ccy;
ctx.fillStyle = '#00e5ff';
ctx.font = '600 12px "Noto Sans SC"';
ctx.textAlign = 'center';
ctx.fillText('吸附固定', lx, ly - 14);
ctx.fillStyle = '#5a7a9a';
ctx.font = '300 10px "Noto Sans SC"';
ctx.fillText('攀爬摩擦力', lx, ly + 2);
ctx.fillText('防跌落力', lx, ly + 16);
// 左箭头
ctx.strokeStyle = 'rgba(0,229,255,0.5)';
ctx.lineWidth = 1.5;
drawArrow(lx + 42, ly, ccx - pulseR - 8, ly, 6);
// 右侧:灰尘抽吸
const rx = px + pw - 55;
const ry = ccy;
ctx.fillStyle = '#ff7b3a';
ctx.font = '600 12px "Noto Sans SC"';
ctx.textAlign = 'center';
ctx.fillText('灰尘抽吸', rx, ry - 14);
ctx.fillStyle = '#7a6a5a';
ctx.font = '300 10px "Noto Sans SC"';
ctx.fillText('表面清洁', rx, ry + 2);
ctx.fillText('灰尘收集', rx, ry + 16);
// 右箭头
ctx.strokeStyle = 'rgba(255,123,58,0.5)';
ctx.lineWidth = 1.5;
drawArrow(ccx + pulseR + 8, ry, rx - 42, ry, 6);
// 底部说明
ctx.fillStyle = '#4a5580';
ctx.font = '300 10px "Noto Sans SC"';
ctx.textAlign = 'center';
ctx.fillText('同一负压场 → 同时完成两项功能,零额外执行机构', px + pw / 2, py + ph - 14);
}
// 绘制微孔详图
function drawDetailPanel() {
if (!showDetail) return;
const px = W - 340;
const py = 290;
const pw = 310;
const ph = 260;
// 面板背景
ctx.fillStyle = CFG.colors.ifrBg;
roundRect(ctx, px, py, pw, ph, 10);
ctx.fill();
ctx.strokeStyle = CFG.colors.ifrBorder;
ctx.lineWidth = 1;
roundRect(ctx, px, py, pw, ph, 10);
ctx.stroke();
// 标题
ctx.fillStyle = '#00e5ff';
ctx.font = '700 14px "Rajdhani","Noto Sans SC"';
ctx.textAlign = 'center';
ctx.fillText('微孔截面详图', px + pw / 2, py + 24);
// 截面图
const sx = px + 30;
const sy = py + 45;
const sw = pw - 60;
const sh = 180;
// 外层柔性硅胶
const siliconeY = sy;
const siliconeH = 35;
ctx.fillStyle = '#1e2a48';
roundRect(ctx, sx, siliconeY, sw, siliconeH, 4);
ctx.fill();
ctx.strokeStyle = '#2e3e68';
ctx.lineWidth = 1;
roundRect(ctx, sx, siliconeY, sw, siliconeH, 4);
ctx.stroke();
// 硅胶标签
ctx.fillStyle = '#5a6a90';
ctx.font = '400 10px "Noto Sans SC"';
ctx.textAlign = 'left';
ctx.fillText('柔性硅胶外层', sx + sw + 8, siliconeY + siliconeH / 2 + 4);
// 微孔(3个放大的孔)
const holeR = 8;
const holeY = siliconeY + siliconeH / 2;
const holeSpacing = sw / 4;
for (let i = 0; i < 3; i++) {
const hx = sx + holeSpacing * (i + 0.5);
// 孔洞
ctx.beginPath();
ctx.arc(hx, holeY, holeR, 0, Math.PI * 2);
ctx.fillStyle = '#060b18';
ctx.fill();
ctx.strokeStyle = '#2e4070';
ctx.lineWidth = 0.8;
ctx.stroke();
// 气流箭头(向上进入腔体)
const isContact = i < 2; // 前两个孔在接触面
if (isContact) {
const flowAlpha = 0.4 + 0.3 * Math.sin(time * 4 + i);
ctx.strokeStyle = `rgba(0,229,255,${flowAlpha})`;
ctx.lineWidth = 1.5;
// 向上的气流
const arrowY1 = holeY + holeR + 15;
const arrowY2 = holeY - holeR - 5;
ctx.beginPath();
ctx.moveTo(hx, arrowY1);
ctx.lineTo(hx, arrowY2);
ctx.stroke();
drawArrow(hx, arrowY1, hx, arrowY2, 4);
// 灰尘粒子进入
const dustAlpha = 0.5 + 0.3 * Math.sin(time * 6 + i * 2);
ctx.fillStyle = `rgba(255,123,58,${dustAlpha})`;
for (let d = 0; d < 3; d++) {
const dx = hx + (Math.sin(time * 3 + d * 2.1 + i) * 6);
const dy = holeY + holeR + 5 + d * 6 + (time * 20 + i * 30) % 20;
if (dy < holeY + holeR + 25) {
ctx.beginPath();
ctx.arc(dx, dy, 2, 0, Math.PI * 2);
ctx.fill();
}
}
// 阀门开启指示
ctx.fillStyle = CFG.colors.valveOpen;
ctx.globalAlpha = 0.5 + 0.3 * Math.sin(time * 5);
roundRect(ctx, hx - 3, holeY - holeR - 3, 6, 4, 1);
ctx.fill();
ctx.globalAlpha = 1;
} else {
// 阀门关闭指示
ctx.fillStyle = CFG.colors.valveClosed;
ctx.globalAlpha = 0.4;
roundRect(ctx, hx - 3, holeY - holeR - 3, 6, 4, 1);
ctx.fill();
ctx.globalAlpha = 1;
}
}
// 中空腔体
const cavityY = siliconeY + siliconeH + 2;
const cavityH = 40;
ctx.fillStyle = '#080e1e';
roundRect(ctx, sx, cavityY, sw, cavityH, 4);
ctx.fill();
ctx.strokeStyle = '#1e2d55';
ctx.lineWidth = 0.8;
roundRect(ctx, sx, cavityY, sw, cavityH, 4);
ctx.stroke();
// 腔体标签
ctx.fillStyle = '#5a6a90';
ctx.font = '400 10px "Noto Sans SC"';
ctx.textAlign = 'left';
ctx.fillText('中空腔体(连通电机)', sx + sw + 8, cavityY + cavityH / 2 + 4);
// 腔体内气流
ctx.strokeStyle = `rgba(0,229,255,${0.15 + 0.1 * Math.sin(time * 3)})`;
ctx.lineWidth = 1;
ctx.setLineDash([4, 6]);
ctx.lineDashOffset = -time * 40;
ctx.beginPath();
ctx.moveTo(sx + 20, cavityY + cavityH / 2);
ctx.lineTo(sx + sw - 20, cavityY + cavityH / 2);
ctx.stroke();
ctx.setLineDash([]);
// 气流方向箭头
ctx.fillStyle = `rgba(0,229,255,${0.3 + 0.15 * Math.sin(time * 3)})`;
ctx.font = '16px "Rajdhani"';
ctx.textAlign = 'center';
ctx.fillText('→ → →', sx + sw / 2, cavityY + cavityH / 2 + 5);
// 阀门分区标注
ctx.fillStyle = '#4a5580';
ctx.font = '300 9px "Noto Sans SC"';
ctx.textAlign = 'center';
ctx.fillText('阀门开', sx + holeSpacing * 0.5, siliconeY - 6);
ctx.fillText('阀门开', sx + holeSpacing * 1.5, siliconeY - 6);
ctx.fillStyle = '#6a4050';
ctx.fillText('阀门关', sx + holeSpacing * 2.5, siliconeY - 6);
// 尺寸标注
ctx.strokeStyle = 'rgba(128,144,184,0.3)';
ctx.lineWidth = 0.5;
// 微孔孔径标注
const标注X = sx + holeSpacing * 0.5;
ctx.beginPath();
ctx.moveTo(标注X - holeR, holeY + holeR + 2);
ctx.lineTo(标注X - holeR, holeY + holeR + 8);
ctx.moveTo(标注X + holeR, holeY + holeR + 2);
ctx.lineTo(标注X + holeR, holeY + holeR + 8);
ctx.moveTo(标注X - holeR, holeY + holeR + 5);
ctx.lineTo(标注X + holeR, holeY + holeR + 5);
ctx.stroke();
ctx.fillStyle = '#5a6a90';
ctx.font = '300 8px "Rajdhani"';
ctx.textAlign = 'center';
ctx.fillText('Ø1.5mm', 标注X, holeY + holeR + 15);
}
// 绘制参数面板
function drawParamPanel() {
const px = W - 340;
const py = 570;
const pw = 310;
const ph = 140;
ctx.fillStyle = CFG.colors.ifrBg;
roundRect(ctx, px, py, pw, ph, 10);
ctx.fill();
ctx.strokeStyle = CFG.colors.ifrBorder;
ctx.lineWidth = 1;
roundRect(ctx, px, py, pw, ph, 10);
ctx.stroke();
ctx.fillStyle = '#00e5ff';
ctx.font = '700 14px "Rajdhani","Noto Sans SC"';
ctx.textAlign = 'center';
ctx.fillText('核心参数 · 适用边界', px + pw / 2, py + 24);
ctx.textAlign = 'left';
ctx.font = '400 11px "Noto Sans SC"';
const params = [
{ label: '微孔孔径', value: '1.5 mm', color: '#00e5ff' },
{ label: '腔体负压', value: `-${pressure.toFixed(1)} kPa`, color: '#00e5ff' },
{ label: '履带材质', value: '柔性硅胶', color: '#8090b8' },
{ label: '失效条件', value: '镂空格栅 / 长毛地毯', color: '#ff2d55' },
];
params.forEach((p, i) => {
const ty = py + 48 + i * 24;
ctx.fillStyle = '#4a5580';
ctx.fillText(p.label, px + 20, ty);
ctx.fillStyle = p.color;
ctx.textAlign = 'right';
ctx.fillText(p.value, px + pw - 20, ty);
ctx.textAlign = 'left';
});
}
/* ============ 工具函数 ============ */
function roundRect(ctx, x, y, w, h, r) {
r = Math.min(r, w / 2, h / 2);
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y);
ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r);
ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h);
ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r);
ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
}
/* ============ 主动画循环 ============ */
let lastTime = 0;
function loop(timestamp) {
const dt = Math.min((timestamp - lastTime) / 1000, 0.05);
lastTime = timestamp;
time += dt * speed;
// 楼梯滚动(模拟攀爬)
scrollOffset = (time * 18) % (CFG.stairRiser + CFG.stairTread);
// 清空画布
ctx.clearRect(0, 0, W, H);
// 绘制各层
drawBackground();
drawStairs();
drawAirflow();
drawRobot();
drawParticles();
drawLabels();
drawIFRPanel();
drawDetailPanel();
drawParamPanel();
// 左下角攀爬进度指示
drawClimbIndicator();
requestAnimationFrame(loop);
}
function drawClimbIndicator() {
const ix = 40;
const iy = H - 80;
ctx.fillStyle = 'rgba(10,16,32,0.85)';
roundRect(ctx, ix, iy, 160, 50, 8);
ctx.fill();
ctx.strokeStyle = '#1e2d55';
ctx.lineWidth = 1;
roundRect(ctx, ix, iy, 160, 50, 8);
ctx.stroke();
ctx.fillStyle = '#5a6a90';
ctx.font = '400 10px "Noto Sans SC"';
ctx.textAlign = 'left';
ctx.fillText('攀爬状态', ix + 12, iy + 18);
// 进度条
const progress = (scrollOffset / (CFG.stairRiser + CFG.stairTread));
const barW = 136;
const barH = 6;
const barX = ix + 12;
const barY = iy + 28;
ctx.fillStyle = '#1a2540';
roundRect(ctx, barX, barY, barW, barH, 3);
ctx.fill();
ctx.fillStyle = '#00e5ff';
roundRect(ctx, barX, barY, barW * progress, barH, 3);
ctx.fill();
ctx.fillStyle = '#8090b8';
ctx.font = '600 10px "Rajdhani"';
ctx.textAlign = 'right';
ctx.fillText(`${Math.floor(progress * 100)}%`, ix + 148, iy + 18);
}
/* ============ 启动 ============ */
window.addEventListener('DOMContentLoaded', init);
</script>
</body>
</html>
这是一个完整的负压吸附履带攀爬机器人 IFR 原理动画实现,以下是关键设计要点:
视觉设计
- 采用深色"全息蓝图"风格,以深海军蓝为底色,青色(#00e5ff)作为负压吸附的主视觉色,橙色(#ff7b3a)标识灰尘颗粒,绿色/红色区分阀门开关状态
- 背景带有半透明网格和径向氛围光,营造技术感
核心动画
- 楼梯滚动 — 楼梯持续向下平移,机器人相对静止,形成攀爬的视觉错觉
- 微孔滚动 — 履带表面微孔沿履带方向移动,模拟履带旋转
- 吸附区脉冲 — 接触面处的青色发光区和力箭头呈呼吸式脉动,强度随负压滑块联动
- 灰尘粒子流 — 粒子从楼梯表面生成,沿曲线流入履带腔体再进入集尘盒
- 阀门状态 — 接触面阀门绿色闪烁(开启),非接触面阀门红色(关闭)
- 电机风扇旋转 — 内部吸尘电机叶片持续转动
IFR 原理突出
- 右上角 IFR 面板以"同一负压场"为中心,向左右分别指向"吸附固定"和"灰尘抽吸"两项功能,用箭头和色彩区分,直观展示"一源双效"
- 右侧微孔截面详图放大展示分区阀门开闭逻辑与气流路径
- 参数面板列出关键数值与失效边界
交互控制
- 负压强度滑块:实时调节吸附区发光强度和粒子生成密度
- 动画速度滑块:控制整体播放速度
- IFR 原理 / 微孔详图按钮:切换右侧面板显示
积分规则:第一轮对话扣减8分,后续每轮扣6分
等待动画代码生成...
