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

1162 lines
58 KiB
JavaScript
Raw Permalink 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 行 / anim4-demo 页共用)
* AnimVersion.init(container):在容器内建 DOM进入视口后自动无限循环播放。
*
* 分镜:
* 1) 赶工:用户需求以气泡在右上角堆叠(新的从下面浮现,旧的上移+边缘虚化),智能体浮在纸上方持笔书写,
* 眼睛全程盯笔尖;每写完一条眼睛更困一点、身体微塌。
* 2) 闯祸:第 5 个节点「清理工作区」→ 智能体带笔跳下纸面,纸翻正变形为终端,
* 命令逐字敲入 rm -rf cache/ 并运行(笔持在手上,不参与打字)。
* 3) 惊吓:第 6 个节点「我让你整理工作区,不是让你全删了!」→ 猛醒瞪眼,跳起+左右看+左右晃同时发生,
* 笔从手中甩飞,翻滚落地弹跳几次后平躺;连敲两条 git 抢救均报「没被追踪」,
* 第 7 个节点「你个吃白饭的蓝色大肥鱼!」落下,绝望拉拉。
* 4) 穿越:真 3D 时光机(双开门电话亭)左侧落下 → 开门 → 团子步走进 → 关门浮空微旋 →
* 对话记录慢滚动、后三条指令气泡均匀节奏逐字湮灭(空壳脱离滚动原地定住、边下坠边淡出),
* 世界同步倒带(终端变回纸、命令倒删、行倒抹);
* 笔全程躺在地上不受影响。
* 5) 归来:落地 → 开门探头左右看 → 走出落地 → 团子步到笔旁蹲下捡笔 →
* 带笔走回右侧 → 跳回纸上工作位 → 时光机升起淡出。循环。
*
* 舞台固定 540×380 逻辑尺寸,等比缩放居中;所有流程等待与 tween 均按 rAF
* 累计「可见时间」(后台标签页整体冻结,恢复不跳变)。
*/
window.AnimVersion = (function () {
'use strict';
/* 站点多语言locale 由 setLocale 注入(默认 zh-CNT(zh, en) 就地双语。
切换语言后 setLocale 重新 init 重建 DOM气泡文案更新并从头续播本场景。
gen 为模块级:重新 init 时递增rafWait/tween 捕获启动时 gen
检测到换代即静默停帧——作废旧闭包里的未完成序列,避免旧动画继续跑。 */
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);
}
var LOGICAL_W = 540, LOGICAL_H = 380;
var CONTENT_CY = 165; /* 内容包围盒中心(气泡区 ~10 ~ 地面 322 */
/* ── 布局常量(舞台本地坐标) ── */
var BUBBLE_GAP = 6; /* 气泡间距margin-top */
var SHELL_DROP = 14; /* 碎裂空壳原地定住后的下坠距离px与淡出同 tween空壳已脱离慢滚动transform 自写) */
var PAPER_TILT = { rx: 42, rz: -7, ty: 0 }; /* 与 03 同款倾斜角 */
var PAPER_FLAT = { rx: 0, rz: 0, ty: -12 };
var LINE_W = [44, 58, 38, 52]; /* 纸面 4 行目标宽度(% */
var AGENT_HOME = { x: 400, y: 86 }; /* 纸上悬浮工作位(视觉中心 x435 / 脚 y145与纸面间距 44.5 同 03 */
var AGENT_GROUND_Y = 255; /* 地面站姿:六边形视觉底(盒高 84.6% 处 = y+59贴地面线 314 */
var AGENT_TERM_X = 430; /* 终端阶段站位(视觉中心 x465 */
var HAND_DX = 46, HAND_DY = 58;/* 持笔笔尖相对 agent 左上的偏移(靠下靠左) */
var PEN_NIB_DX = 4.4, PEN_NIB_DY = 35; /* 笔尖相对笔外层的偏移 */
var PEN_FLOOR_Y = 294; /* 笔落地时外层 y笔身中心贴地 314 */
var PEN_FLAT_ROT = 44.5; /* 笔身平躺角SVG 笔身 45° 斜,转 44.5° 后水平躺地) */
var BOOTH_X = 46, BOOTH_Y = 137; /* 时光机落地位(左上);体底恰在地面 318 */
var BOOTH_FLY_Y = -380; /* 藏在舞台上方 */
var BOOTH_FLOAT_DY = -46; /* 浮空抬升 */
var SLEEPY = [0.8, 0.62, 0.45, 0.32]; /* 每写完一条的困倦程度(眼睛高度比例) */
var CMD = 'rm -rf ./*'; /* 把「清理工作区」理解成删光工作区所有文件 */
var CMD_OUT = 'removed 23 files';
/* 抢救段:甩笔后他先试图用 git 把工作区救回来——两条指令都报「文件没被追踪」 */
var RESCUE_1 = 'git restore ./';
var RESCUE_2 = 'git checkout ./';
var RESCUE_ERR = 'not tracked by git'; /* 与 removed 23 files 同为裸输出文本 */
function init(container) {
lastContainer = container;
gen++; /* 换代:作废此前 init 的未完成动画序列rafWait/tween 检测到换代即停帧) */
/* 七条用户指令全文:以气泡在右上角堆叠(风格同 01 的用户气泡);本地化在每次 init 时按当前 locale 求值 */
var NODES = [
{ full: T('帮我写一份官网设计稿', 'Write me a homepage design draft') },
{ full: T('把配色方案那节补充完整', 'Fill in the color palette section') },
{ full: T('再补一版深色模式的设计', 'Also add a dark mode design') },
{ full: T('把字体层级再调细一点', 'Fine-tune the typography hierarchy') },
{ full: T('清理一下工作区吧', 'Clean up the workspace') },
{ full: T('我让你整理工作区,不是让你全删了!', 'I said tidy the workspace, not wipe it all!') },
{ full: T('你个吃白饭的蓝色大肥鱼!', 'You good-for-nothing blue fat fish!') }
];
container.innerHTML =
'<div class="anim4-window">' +
'<div class="anim4-stage" id="anim4Stage">' +
'<div class="anim4-bubbles" id="bubbles"></div>' +
'<div class="anim4-shadow anim4-shadow-booth" id="boothShadow" style="left:63px;top:313px"></div>' +
'<div class="anim4-shadow anim4-shadow-agent" id="agentShadow"></div>' +
'<div class="anim4-paper" id="paper">' +
'<div class="anim4-paper-sheet">' +
'<div class="anim4-pl" data-i="0"><i class="anim4-pl-end"></i></div>' +
'<div class="anim4-pl" data-i="1"><i class="anim4-pl-end"></i></div>' +
'<div class="anim4-pl" data-i="2"><i class="anim4-pl-end"></i></div>' +
'<div class="anim4-pl" data-i="3"><i class="anim4-pl-end"></i></div>' +
'</div>' +
'<div class="anim4-term" id="term">' +
'<div class="anim4-term-body" id="termBody">' +
'<div class="anim4-term-line"><span class="anim4-term-p">&gt;</span><span class="anim4-term-c" id="termCmd"></span><i class="anim4-term-caret" id="termCaret"></i></div>' +
'<div class="anim4-term-line anim4-term-out" id="termOut">' + CMD_OUT + '</div>' +
'<div class="anim4-term-line anim4-term-new" id="termNew"><span class="anim4-term-p">&gt;</span><span class="anim4-term-c"></span><i class="anim4-term-caret"></i></div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="anim4-agent" id="agent"><span class="anim4-agent-avatar" id="agentAvatar"></span></div>' +
'<div class="anim4-pen-pos" id="penPos">' +
'<svg class="anim4-pen" id="pen" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
'<path fill="var(--bg-1)" d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />' +
'<path d="m15 5 4 4" />' +
'<circle id="penNibRef4" class="anim4-pen-ref" cx="2.65" cy="21.02" r="0.7" />' +
'</svg>' +
'</div>' +
'<div class="anim4-booth" id="booth">' +
/* 整机 2D 矢量化:所有面手算透视投影后屏幕空间绘制(根治 3D 面片纹理重采样锯齿)。
三层:后层 svg z1左外墙 + clip 到门洞投影的内景四面)< 智能体挂载层 z2
< 前层 svg z3门槛/双梃/双开门/屋顶/尖顶)——关门门板即挡住他,同原前后壳语义 */
'<svg class="anim4-booth-wire anim4-booth-wire-back" id="boothWireBack" viewBox="0 0 150 220" aria-hidden="true">' +
'<defs><clipPath id="bwDoorClip"><polygon id="bwDoorClipPoly" points="" /></clipPath></defs>' +
'<g clip-path="url(#bwDoorClip)">' +
'<polygon id="bwInBack" points="" />' +
'<polygon id="bwInSideR" points="" />' +
'<polygon id="bwInCeil" points="" />' +
'<polygon id="bwInFloor" points="" />' +
'</g>' +
'<polygon id="bwSideL" points="" />' +
'</svg>' +
'<div class="anim4-booth-inner" id="boothInner"></div>' +
'<svg class="anim4-booth-wire" id="boothWireFront" viewBox="0 0 150 220" aria-hidden="true">' +
'<polygon id="bwThreshold" points="" />' +
'<polygon id="bwJambL" points="" />' +
'<polygon id="bwJambR" points="" />' +
'<g id="bwDoorL"><polygon id="bwDoorLp" points="" /></g>' +
'<g id="bwDoorR"><polygon id="bwDoorRp" points="" /></g>' +
'<polygon id="bwSlopeL" points="" />' +
'<polygon id="bwSlopeF" points="" />' +
'<line id="bwRod" x1="75" y1="2" x2="75" y2="9.5" />' +
'<circle id="bwKnob" cx="75" cy="-3.5" r="5" />' +
'</svg>' +
'</div>' +
'</div>' +
'</div>';
/* 元素查找一律限定在自身容器内:主页上 03/04 动画同页共存03 也有
id="paper" / id="penPos"document.getElementById 会按文档序抓到 03 的元素,
导致笔位写错对象、is-term 加错纸(终端段整段不可见)。容器内查找对此类碰撞免疫。 */
function byId(id) { return container.querySelector('#' + id); }
var stage = byId('anim4Stage');
var bubblesEl = byId('bubbles');
var paper = byId('paper');
var term = byId('term');
var termBody = byId('termBody');
var termCmd = byId('termCmd');
var termCaret = byId('termCaret');
var termOut = byId('termOut');
var termNew = byId('termNew');
var agentEl = byId('agent');
var penPos = byId('penPos');
var pen = byId('pen');
var booth = byId('booth');
var boothInner = byId('boothInner');
var boothShadow = byId('boothShadow');
var agentShadow = byId('agentShadow');
/* 时光机 2D 覆盖层元素 */
var bwDoorClipPoly = byId('bwDoorClipPoly');
var bwSideL = byId('bwSideL');
var bwInBack = byId('bwInBack');
var bwInSideR = byId('bwInSideR');
var bwInCeil = byId('bwInCeil');
var bwInFloor = byId('bwInFloor');
var bwThreshold = byId('bwThreshold');
var bwJambL = byId('bwJambL');
var bwJambR = byId('bwJambR');
var bwSlopeL = byId('bwSlopeL');
var bwSlopeF = byId('bwSlopeF');
var bwRod = byId('bwRod');
var bwKnob = byId('bwKnob');
/* 门板元素组:面 polygon + 5 条横纹 + 把手SVG 元素必须 createElementNS 创建) */
function doorElsBuild(gid, pid) {
var g = byId(gid);
var o = { plate: byId(pid), stripes: [], knob: null };
var NS = 'http://www.w3.org/2000/svg';
for (var i = 0; i < 5; i++) {
var ln = document.createElementNS(NS, 'line');
ln.setAttribute('class', 'bw-door-stripe');
g.appendChild(ln);
o.stripes.push(ln);
}
var k = document.createElementNS(NS, 'circle');
k.setAttribute('class', 'bw-door-knob');
k.setAttribute('r', '1.7');
g.appendChild(k);
o.knob = k;
return o;
}
var doorElsL = doorElsBuild('bwDoorL', 'bwDoorLp');
var doorElsR = doorElsBuild('bwDoorR', 'bwDoorRp');
var lines = Array.prototype.slice.call(stage.querySelectorAll('.anim4-pl'));
/* ── 时光机 2D 矢量化投影系统(整机根治 3D 面片纹理重采样锯齿) ──
所有面 = 体心局部坐标x 右、y 下、z 朝观众)的矩形平面,每帧按偏航 rotateY(24°+sway)
手算投影(等价原 scene 的 perspective:760、原点 booth 中心 75,110屏幕空间 SVG 画——
原生抗锯齿永远锐利,摆动时逐帧一致。
96×96×142 正方形柱x/z ±48y ±71正面左右梃各 18 + 门洞 60视角露正面+左侧面。
屋顶四棱锥底 108×108±54锥高 28锥底在体顶面 y=-72。内景四面内缩 1px 防共面。 */
var PERSP = 760, BCX = 75, BCY = 110;
var DOOR_ANG = 140; /* 双开门全开角(铰链 x=∓30 竖轴,向观众外翻) */
var DOOR_STRIPE_Y = [-58, -30, -2, 26, 54]; /* 门板横纹高度(原 repeating-gradient 13+28k 的全局 y */
var WIRE_PTS = {
FL: [-54, -72, 54], FR: [54, -72, 54], /* 锥底前左/前右(挑出体身 6px */
BL: [-54, -72, -54], /* 锥底后左(后右不可见不画) */
AP: [0, -100, 0], /* 锥顶(锥高 28 */
RT: [0, -108, 0], RB: [0, -100.5, 0], /* 尖顶短杆顶/底(底略高于锥顶防接缝) */
KB: [0, -113.5, 0] /* 小球中心 */
};
function projectWirePt(p, yaw) {
var c = Math.cos(yaw), s = Math.sin(yaw);
var x = p[0] * c + p[2] * s; /* CSS rotateY(θ)x' = x·cosθ + z·sinθ */
var z = -p[0] * s + p[2] * c; /* z' = -x·sinθ + z·cosθ */
var k = PERSP / (PERSP - z);
return [BCX + x * k, BCY + p[1] * k, z];
}
function wirePtsAttr() {
var out = [];
for (var i = 0; i < arguments.length; i++) out.push(arguments[i][0].toFixed(1) + ',' + arguments[i][1].toFixed(1));
return out.join(' ');
}
var doorA = 0;
/* 门板角度变化即时重投影doorTween 驱动) */
function doorSet(a) { doorA = a; drawBooth(); }
function doorTween(target, ms) {
var from = doorA;
return tween(ms, function (p) {
doorA = lerp(from, target, easeInOut(p));
doorSet(doorA);
});
}
function drawBooth() {
var yaw = (24 + boothState.sway) * Math.PI / 180;
function P(p) { return projectWirePt(p, yaw); }
function quad(el, a, b, c, d) { el.setAttribute('points', wirePtsAttr(P(a), P(b), P(c), P(d))); }
/* 门板:铰链竖边 (hx,∓71,48) → 外缘竖边 (hx+30·dirX, ∓71, 48+30·dirZ);横纹/把手随门板翻转 */
function drawDoor(d, hx, dirX, dirZ) {
var ex = 30 * dirX, ez = 30 * dirZ;
quad(d.plate, [hx, -71, 48], [hx + ex, -71, 48 + ez], [hx + ex, 71, 48 + ez], [hx, 71, 48]);
for (var i = 0; i < DOOR_STRIPE_Y.length; i++) {
var y = DOOR_STRIPE_Y[i];
var p1 = P([hx, y, 48]), p2 = P([hx + ex, y, 48 + ez]);
var ln = d.stripes[i];
ln.setAttribute('x1', p1[0].toFixed(1)); ln.setAttribute('y1', p1[1].toFixed(1));
ln.setAttribute('x2', p2[0].toFixed(1)); ln.setAttribute('y2', p2[1].toFixed(1));
}
var kp = P([hx + 27.5 / 30 * ex, -3, 48 + 27.5 / 30 * ez]); /* 把手靠中缝侧y=-3 */
d.knob.setAttribute('cx', kp[0].toFixed(1));
d.knob.setAttribute('cy', kp[1].toFixed(1));
}
/* 门洞投影 → 内景 clipPath跟随摆动逐帧一致只有透过门洞可见的内景被画出 */
bwDoorClipPoly.setAttribute('points', wirePtsAttr(P([-30, -71, 48]), P([30, -71, 48]), P([30, 71, 48]), P([-30, 71, 48])));
/* 后层内景四面clip 内全幅画)+ 左外墙 */
quad(bwInBack, [-48, -70, -47], [48, -70, -47], [48, 70, -47], [-48, 70, -47]);
quad(bwInSideR, [47, -70, -47], [47, -70, 48], [47, 70, 48], [47, 70, -47]);
quad(bwInCeil, [-48, -70, -47], [48, -70, -47], [48, -70, 48], [-48, -70, 48]);
quad(bwInFloor, [-48, 70, -47], [48, 70, -47], [48, 70, 48], [-48, 70, 48]);
quad(bwSideL, [-48, -71, -48], [-48, -71, 48], [-48, 71, 48], [-48, 71, -48]);
/* 前层:门槛 / 左右梃(开门后走进深处的他被左梃自然遮挡) */
quad(bwThreshold, [-30, 70, 40.5], [30, 70, 40.5], [30, 70, 47.5], [-30, 70, 47.5]);
quad(bwJambL, [-48, -71, 48], [-30, -71, 48], [-30, 71, 48], [-48, 71, 48]);
quad(bwJambR, [30, -71, 48], [48, -71, 48], [48, 71, 48], [30, 71, 48]);
/* 双开门:外翻 140°·doorA左门外缘 +cosφ右门镜像 -cosφz 均 +sinφ */
var phi = DOOR_ANG * doorA * Math.PI / 180;
var cp = Math.cos(phi), sp = Math.sin(phi);
drawDoor(doorElsL, -30, cp, sp);
drawDoor(doorElsR, 30, -cp, sp);
/* 屋顶两坡画家算法SVG 无 z-buffer平均深度远先画尖顶恒最上 */
var FL = P(WIRE_PTS.FL), FR = P(WIRE_PTS.FR), BL = P(WIRE_PTS.BL), AP = P(WIRE_PTS.AP);
bwSlopeF.setAttribute('points', wirePtsAttr(FL, FR, AP));
bwSlopeL.setAttribute('points', wirePtsAttr(BL, FL, AP));
var fDepth = (FL[2] + FR[2] + AP[2]) / 3, lDepth = (BL[2] + FL[2] + AP[2]) / 3;
var front = bwSlopeF.parentNode;
if (fDepth >= lDepth) { front.appendChild(bwSlopeL); front.appendChild(bwSlopeF); }
else { front.appendChild(bwSlopeF); front.appendChild(bwSlopeL); }
front.appendChild(bwRod);
front.appendChild(bwKnob);
/* 尖顶:短杆 + 小球(竖杆 + 正圆天然 billboard */
var RT = P(WIRE_PTS.RT), RB = P(WIRE_PTS.RB), KB = P(WIRE_PTS.KB);
bwRod.setAttribute('x1', RT[0].toFixed(1)); bwRod.setAttribute('y1', RT[1].toFixed(1));
bwRod.setAttribute('x2', RB[0].toFixed(1)); bwRod.setAttribute('y2', RB[1].toFixed(1));
bwKnob.setAttribute('cx', KB[0].toFixed(1)); bwKnob.setAttribute('cy', KB[1].toFixed(1));
}
/* 门洞标定:投影公式直接算(落地静止态 sway=0对齐智能体挂载层
比旧的 doorRef 实测更准——与门板/梃共用同一公式,必然严丝合缝 */
var doorGeom = null; /* { cxStage } */
function calibrateInner() {
var yaw = 24 * Math.PI / 180;
var tl = projectWirePt([-30, -71, 48], yaw);
var br = projectWirePt([30, 71, 48], yaw);
boothInner.style.left = tl[0].toFixed(1) + 'px';
boothInner.style.top = tl[1].toFixed(1) + 'px';
boothInner.style.width = (br[0] - tl[0]).toFixed(1) + 'px';
boothInner.style.height = (br[1] - tl[1]).toFixed(1) + 'px';
doorGeom = { cxStage: BOOTH_X + (tl[0] + br[0]) / 2 };
}
/* 智能体眼睛:不做鼠标追踪,全程由动画驱动(看笔尖/闭眼/瞪眼) */
var eyes = null;
if (window.AstrionAvatar) {
var avatarSvg = AstrionAvatar.mount(byId('agentAvatar'), { mode: 'idle', blink: false, track: false });
eyes = AstrionAvatar.handle(avatarSvg);
}
var EYE_IDLE = window.AstrionAvatar ? window.AstrionAvatar.EYE_IDLE : null;
var curEyePath = EYE_IDLE; /* 当前眼睛形状目标(眨眼回落用) */
function morphEyes(nums, ms) {
if (!eyes) return;
curEyePath = nums;
eyes.morphEyesTo(nums, ms);
}
/* 等比缩放(同 03视口 rect 读数统一除 curScale 换回舞台本地坐标 */
var curScale = 1;
function updateScale() {
var w = container.clientWidth, h = container.clientHeight;
curScale = Math.min(w / LOGICAL_W, h / LOGICAL_H) || 1;
stage.style.top = 'calc(50% + ' + ((LOGICAL_H / 2 - CONTENT_CY) * curScale).toFixed(1) + 'px)';
stage.style.transform = 'translate(-50%, -50%) scale(' + curScale + ')';
}
if (window.ResizeObserver) new ResizeObserver(updateScale).observe(container);
window.addEventListener('resize', updateScale);
function stageRect() { return stage.getBoundingClientRect(); }
function toLocalX(clientX, sr) { return (clientX - sr.left) / curScale; }
function toLocalY(clientY, sr) { return (clientY - sr.top) / curScale; }
/* ── rAF 可见时间计时(后台冻结,恢复不跳变) ── */
function rafWait(ms) {
var myGen = gen; /* 捕获启动时代次:换代后静默停帧(不 resolve作废旧序列 */
return new Promise(function (resolve) {
var acc = 0, last = performance.now();
function tick(now) {
acc += Math.min(now - last, 100);
last = now;
if (acc >= ms) { resolve(); return; }
if (myGen !== gen) return; /* 已换代:停止排队新帧,让旧序列挂起 */
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
});
}
function tween(ms, fn) {
var myGen = gen; /* 捕获启动时代次:换代后静默停帧(不 resolve作废旧序列 */
return new Promise(function (resolve) {
var acc = 0, last = performance.now();
function tick(now) {
acc += Math.min(now - last, 100);
last = now;
var p = Math.min(acc / ms, 1);
fn(p, acc);
if (p >= 1) { resolve(); return; }
if (myGen !== gen) return; /* 已换代:停止排队新帧,让旧序列挂起 */
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
});
}
function easeOut(p) { return 1 - (1 - p) * (1 - p); }
function easeIn(p) { return p * p; }
function easeInOut(p) { return p < 0.5 ? 2 * p * p : 1 - 2 * (1 - p) * (1 - p); }
function clamp01(v) { return Math.max(0, Math.min(1, v)); }
function lerp(a, b, t) { return a + (b - a) * t; }
/* ── 用户指令气泡(右上角堆叠) ── */
/* 新指令气泡从下面浮现旧的上移、跑出可视区overflow 裁切 + 顶部渐隐遮罩边缘虚化)——
不销毁:倒带时下方两条逐字碎裂、空壳原地定住淡出,上方气泡随堆叠自然回落可视区。
进出场均用 JS tween 驱动 margin/transformrAF 可见时间,后台冻结恢复不跳变)——
margin 动画自然推拉其余气泡,无需 FLIP绝不 tween heightborder-box 钳位致瞬移)。 */
var bubbleEls = [];
/* 按指令序号找气泡:数组在 shatter 时 splice绝不能用绝对序号当下标 */
function bubbleOf(ni) {
for (var i = 0; i < bubbleEls.length; i++) if (bubbleEls[i]._ni === ni) return bubbleEls[i];
return null;
}
function bubbleArrive(k) {
var b = document.createElement('div');
b.className = 'anim4-bubble';
b.textContent = NODES[k].full;
b._ni = k;
/* 纯挤压入场margin-top 从 -hTarget → +GAP 推占位(顶上面),
translateY 从 hTarget+2 → 0 让本体从容器底缘下实体滑入(挤上来)——全程不透明,无淡入。
绝不 tween height——box-sizing:border-box 下 height 小于上下 padding 之和12px
被钳位在 12px前 ~45% 的写入高度实际纹丝不动,首个有效帧旧气泡就被瞬推 12px瞬移感
先以 -999px 挂账入场(同步代码内立即修正,不会被绘制),实测全高后精确抵消。 */
b.style.cssText = 'margin-top:-999px;transform:translateY(999px);';
bubblesEl.appendChild(b);
bubbleEls.push(b);
var hTarget = b.getBoundingClientRect().height / curScale; /* 自然全高gBCR 除 curScale 换回舞台本地坐标) */
var y0 = hTarget + 2; /* 起始藏在容器底缘之下(+2 防边缘露馅) */
b.style.marginTop = (-hTarget).toFixed(1) + 'px'; /* 抵消自身占位:此时旧气泡零位移 */
b.style.transform = 'translateY(' + y0.toFixed(1) + 'px)';
return tween(420, function (p) {
var e = easeInOut(p); /* 零速起步,首帧 dt 偏大也不会「踹」旧气泡 */
b.style.marginTop = lerp(-hTarget, BUBBLE_GAP, e).toFixed(1) + 'px';
b.style.transform = 'translateY(' + (y0 * (1 - e)).toFixed(1) + 'px)';
}).then(function () {
b.style.cssText = ''; /* 入场结束交还类样式margin-top 由 .anim4-bubble 管,同为 6px 无跳变) */
});
}
/* 气泡湮灭碎裂机制参考沙箱撞盾Range 逐字实测 → 逐字碎片 span → 重力/自旋/淡出);
参数为「被时间抹掉」的散裂:从字块中心向外散开 + 轻微上飘,不是向左爆开。
文字碎飞后空壳原地定住、边下坠边淡出(冻精确宽高、脱离慢滚动、不缩占位),回溯收尾才统一移除。 */
function shatterBubble(b, syNow) {
if (!b || !b.firstChild) return;
var ai = bubbleEls.indexOf(b);
if (ai >= 0) bubbleEls.splice(ai, 1);
var text = b.textContent;
var stageR = stage.getBoundingClientRect();
/* 逐字实测Range 天然处理中文宽度;多行气泡每字各得其框) */
var tn = b.firstChild;
var range = document.createRange();
var rects = [];
for (var i = 0; i < tn.length; i++) {
range.setStart(tn, i);
range.setEnd(tn, i + 1);
rects.push(range.getBoundingClientRect());
}
range.detach();
/* 冻结精确宽度+全高(小数,防 0.2px 瞬塌)再清空文字:空壳尺寸纹丝不动——
宽度必须一并冻结,气泡宽自适应内容,清空文字会瞬间塌成 padding 宽22px
然后下坠+淡出——不缩占位,占位由整段慢滚动统一消化,回溯收尾才移除。 */
var fr = b.getBoundingClientRect();
b.style.width = (fr.width / curScale).toFixed(1) + 'px';
b.style.height = (fr.height / curScale).toFixed(1) + 'px';
b.textContent = '';
shatteredShells.push(b);
/* 空壳脱离慢滚动队伍_detached定格在碎裂位置sy0原地「定住湮灭」
边淡出边下坠,淡出全程在视野内——若继续随队滚动,空壳会被容器底缘
overflow 硬裁切拖出视野,淡出看不见(只剩「向下退出显示范围」)。 */
b._detached = true;
var sy0 = syNow;
tween(380, function (p) {
var e = easeInOut(p);
b.style.opacity = (1 - e).toFixed(2);
b.style.filter = 'blur(' + (2 * e).toFixed(1) + 'px)';
b.style.transform = 'translateY(' + (sy0 + SHELL_DROP * e).toFixed(1) + 'px)';
});
var tagCX = 0, tagCY = 0;
for (var i = 0; i < rects.length; i++) { tagCX += rects[i].left + rects[i].width / 2; tagCY += rects[i].top + rects[i].height / 2; }
tagCX /= rects.length; tagCY /= rects.length;
var baseRot = 0;
var shards = [];
for (var i = 0; i < text.length; i++) {
var s = document.createElement('span');
s.className = 'anim4-shard';
s.textContent = text[i] === ' ' ? '\u00A0' : text[i];
stage.appendChild(s);
/* gBCR 视口读数除 curScale 换回舞台本地坐标(主页缩放时碎片不再错位) */
var x0 = (rects[i].left - stageR.left) / curScale, y0 = (rects[i].top - stageR.top) / curScale;
var ccx = rects[i].left + rects[i].width / 2, ccy = rects[i].top + rects[i].height / 2;
var ang = Math.atan2(ccy - tagCY, ccx - tagCX);
var spd = 26 + Math.random() * 90;
s.style.transform = 'translate(' + x0.toFixed(1) + 'px,' + y0.toFixed(1) + 'px) rotate(' + baseRot.toFixed(1) + 'deg)';
shards.push({
el: s, x: x0, y: y0,
vx: Math.cos(ang) * spd + (Math.random() - 0.5) * 60,
vy: Math.sin(ang) * spd - (30 + Math.random() * 90), /* 略带上飘的湮灭感 */
rot: baseRot, vr: (Math.random() - 0.5) * 720,
life: 0, ttl: 0.8 + Math.random() * 0.45
});
}
var g = 720, last = performance.now();
(function shardLoop() {
var now = performance.now();
var dt = Math.min((now - last) / 1000, 0.05);
last = now;
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);
}
if (alive) requestAnimationFrame(shardLoop);
})();
}
/* ── 纸 / 终端 morph ── */
function setPaperTransform(rx, rz, ty) {
paper.style.transform =
'perspective(760px) rotateX(' + rx.toFixed(2) + 'deg) rotateZ(' + rz.toFixed(2) + 'deg)' +
(ty ? ' translateY(' + ty.toFixed(1) + 'px)' : '');
}
function paperMorphTo(target, ms) {
var cur = paper._pose || PAPER_TILT;
return tween(ms, function (p) {
var e = easeInOut(p);
var pose = {
rx: lerp(cur.rx, target.rx, e),
rz: lerp(cur.rz, target.rz, e),
ty: lerp(cur.ty, target.ty, e)
};
paper._pose = pose;
setPaperTransform(pose.rx, pose.rz, pose.ty);
});
}
/* ── 笔 ── */
var nibRef = byId('penNibRef4');
var penTx = 0, penTy = 0;
function setPen(x, y) {
penTx = x; penTy = y;
penPos.style.transform = 'translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px)';
}
/* 参考点精确移到舞台坐标:实测当前位置做差值补偿(同 03 */
function nibTo(x, y, sr) {
var r = nibRef.getBoundingClientRect();
var cx = toLocalX(r.left + r.width / 2, sr);
var cy = toLocalY(r.top + r.height / 2, sr);
setPen(penTx + (x - cx), penTy + (y - cy));
}
function lineTipLocal(line, sr) {
var r = line.querySelector('.anim4-pl-end').getBoundingClientRect();
return { x: toLocalX(r.left, sr), y: toLocalY(r.top, sr) };
}
/* 笔在手上transition 飞行到手持位) */
function penToHand() {
penPos.classList.remove('is-live');
nibTo(agent.x + HAND_DX, agent.y + HAND_DY, stageRect());
}
/* 携带状态:笔钉在手上(逐帧同步,供 walk/hop 帧里调用)——
必须加 is-live 关过渡!否则每帧 setPen 都触发 0.45s 过渡,笔会一直滞后漂移(看起来像自己飞过去) */
function penCarried() {
penPos.classList.add('is-live');
setPen(agent.x + HAND_DX - PEN_NIB_DX, agent.y + HAND_DY - PEN_NIB_DY);
}
/* ── 智能体 ── */
var agent = { x: AGENT_HOME.x, y: AGENT_HOME.y, rot: 0, sx: 1, sy: 1, op: 1 };
function setAgent() {
agentEl.style.transform =
'translate(' + agent.x.toFixed(1) + 'px,' + agent.y.toFixed(1) + 'px)' +
' rotate(' + agent.rot.toFixed(2) + 'deg)' +
' scale(' + agent.sx.toFixed(3) + ',' + agent.sy.toFixed(3) + ')';
agentEl.style.opacity = agent.op.toFixed(2);
/* 地面阴影只在地面上出现,跟随 x */
var onGround = agent.y > AGENT_GROUND_Y - 12 && agentEl.parentNode === stage;
agentShadow.style.opacity = onGround ? '0.3' : '0';
if (onGround) {
agentShadow.style.left = (agent.x + 35 - 28) + 'px';
agentShadow.style.top = '309px';
}
}
/* 重挂载(舞台 ↔ 亭内景层)且保持屏幕视觉位置不变:
两边都是纯 2DgetBoundingClientRect 实测中心对齐即可(无透视无旋转) */
function reparentKeepVisual(newParent) {
var r = agentEl.getBoundingClientRect();
var cx = r.left + r.width / 2, cy = r.top + r.height / 2;
newParent.appendChild(agentEl);
var pr = newParent.getBoundingClientRect();
agent.x = (cx - pr.left) / curScale - 35;
agent.y = (cy - pr.top) / curScale - 35;
/* 保留 sx/sy进出亭时的纵深缩放0.94)不能在换层时丢,否则会跳变 */
setAgent();
}
/* 团子步:左右摇摆 + 低幅弹跳carryPen 时笔钉在手上。
步频随距离(恒定步幅 ≈65px/步,用进度 p 驱动而非时间)——步子与位移同步,
快走/短走都是完整迈步,不会出现「位移快、摆得慢」的平移滑行感 */
function walkTo(xTarget, ms, carryPen) {
var x0 = agent.x, dir = xTarget > x0 ? 1 : -1;
var cycles = Math.max(2, Math.round(Math.abs(xTarget - x0) / 65)); /* 整数步:落地时恰好回正 */
if (eyes) eyes.lookAt(dir * 4, 1.5);
return tween(ms, function (p, acc) {
var e = easeInOut(p);
agent.x = lerp(x0, xTarget, e);
var wob = e * Math.PI * 2 * cycles;
agent.rot = Math.sin(wob) * 7;
agent.y = AGENT_GROUND_Y - Math.abs(Math.sin(wob)) * 3.5;
setAgent();
if (carryPen) penCarried();
}).then(function () {
agent.x = xTarget; agent.y = AGENT_GROUND_Y; agent.rot = 0;
setAgent();
if (carryPen) penCarried();
});
}
/* 跳:下蹲蓄势(慢)→ 起跳瞬间释放、向上拉伸(加速曲线)→ 线性时间抛物线(起跳快/到顶慢/落地快)→ 落地挤压回弹 */
function hopTo(xTarget, yTarget, jumpH, ms, carryPen) {
var x0 = agent.x, y0 = agent.y;
return tween(140, function (p) { /* 下蹲蓄势 */
var k = easeInOut(p);
agent.sx = 1 + 0.12 * k;
agent.sy = 1 - 0.18 * k;
agent.y = y0 + 15 * k; /* 脚下沉补偿:脚贴地 */
setAgent();
if (carryPen) penCarried();
}).then(function () {
return tween(ms, function (p) {
var e = easeInOut(p);
agent.x = lerp(x0, xTarget, e);
agent.y = lerp(y0 + 15, yTarget, e) - jumpH * 4 * p * (1 - p);
/* 起跳拉伸0→18% 从下蹲弹到拉长)→ 空中恢复18%→45% */
var sy;
if (p < 0.18) { sy = 0.82 + 0.34 * easeOut(p / 0.18); }
else if (p < 0.45) { sy = 1.16 - 0.16 * easeInOut((p - 0.18) / 0.27); }
else sy = 1;
agent.sy = sy;
agent.sx = 1 - (sy - 1) * 0.7; /* 面积守恒 */
setAgent();
if (carryPen) penCarried();
});
}).then(function () {
return tween(200, function (p) { /* 落地挤压回弹 */
var k = Math.sin(p * Math.PI);
agent.sy = 1 - 0.14 * k;
agent.sx = 1 + 0.1 * k;
setAgent();
if (carryPen) penCarried();
});
}).then(function () {
agent.x = xTarget; agent.y = yTarget; agent.sx = 1; agent.sy = 1;
setAgent();
if (carryPen) penCarried();
});
}
/* ── 时光机整体运动(下落/浮空/微倾/透明度 + 接触阴影) ── */
var boothState = { dy: BOOTH_FLY_Y, rot: 0, op: 0, sway: 0 };
function setBooth() {
booth.style.transform =
'translate(' + BOOTH_X + 'px,' + (BOOTH_Y + boothState.dy).toFixed(1) + 'px)' +
' rotate(' + boothState.rot.toFixed(2) + 'deg)';
booth.style.opacity = boothState.op.toFixed(2);
/* 落地越深影子越实 */
var h = clamp01(1 - Math.abs(boothState.dy) / 120);
boothShadow.style.opacity = (0.45 * h * boothState.op).toFixed(2);
var s = 0.55 + 0.45 * h;
boothShadow.style.transform = 'scale(' + s.toFixed(2) + ',1)';
drawBooth();
}
/* ── 眨眼调度(仅第 1~2 幕;惊吓后由分镜接管) ── */
var blinksOn = false;
async function blinkLoop() {
while (blinksOn) {
await rafWait(1900 + Math.random() * 1800);
if (!blinksOn || !eyes) return;
eyes.morphEyesTo(window.AstrionAvatar.EYE_BLINK, 85);
await rafWait(100);
if (!blinksOn || !eyes) return;
eyes.morphEyesTo(curEyePath, 140);
}
}
/* 写字时眼睛跟随笔尖(每帧调用) */
function eyesFollowPen() {
if (!eyes) return;
var r = nibRef.getBoundingClientRect();
eyes.lookAtPoint(r.left + r.width / 2, r.top + r.height / 2);
}
/* ── 第一幕:写一行 ── */
async function writeLine(k) {
var line = lines[k];
var sr = stageRect();
/* 飞到行首 */
var lr = line.getBoundingClientRect();
penPos.classList.remove('is-live');
nibTo(toLocalX(lr.left, sr), toLocalY(lr.top + lr.height / 2, sr), sr);
await tween(430, function () { eyesFollowPen(); }); /* 笔飞过去时眼睛就追着笔,不是到位后才看 */
/* 逐帧写:线从行首长到目标宽,笔尖跟右端,眼睛跟笔尖;
摇晃由 JS 驱动:振幅包络起止都收敛到 0°杜绝 CSS 动画接入/断开时的瞬变 */
penPos.classList.add('is-live');
var W = LINE_W[k];
await tween(720, function (p) {
line.style.width = (W * easeOut(p)) + '%';
var sr2 = stageRect();
var tip = lineTipLocal(line, sr2);
nibTo(tip.x, tip.y, sr2);
eyesFollowPen();
var env = Math.sin(Math.min(p / 0.18, 1) * Math.PI * 0.5) *
Math.sin(Math.min((1 - p) / 0.18, 1) * Math.PI * 0.5);
pen.style.transform = 'rotate(' + (Math.sin(p * Math.PI * 2 * 1.7) * 6 * env).toFixed(1) + 'deg)';
});
pen.style.transform = 'rotate(0deg)';
}
/* ── 第二幕:终端打字(笔持在手上不动,字符逐字出现,眼睛看光标) ── */
var typedChars = [];
async function typeCommand() {
for (var i = 0; i < CMD.length; i++) {
var c = document.createElement('span');
c.textContent = CMD[i];
termCmd.appendChild(c);
typedChars.push(c);
if (eyes) {
var cr = termCaret.getBoundingClientRect();
eyes.lookAtPoint(cr.left, cr.top + cr.height / 2);
}
await rafWait(58);
}
}
/* ── 抢救段工具:动态终端行 + 逐字敲入 + 行多上滚(倒带时全部抹除) ── */
var rescueUnits = []; /* 抢救段产生的一切(字符+整行),按创建顺序,倒带时逆序逐一消失 */
var shatteredShells = []; /* 倒带时碎裂气泡的空壳:原地淡出不缩占位,回溯收尾统一移除 */
var termShift = 0;
/* 追加一行终端行(追加即显示,挂进 termBody行数超出内容区时 termBody 整体上滚(真实终端行为) */
function addTermLine(cls, html) {
var d = document.createElement('div');
d.className = cls;
d.innerHTML = html;
termBody.appendChild(d);
rescueUnits.push(d);
var need = Math.max(0, termBody.querySelectorAll('.anim4-term-line').length * 21 - 90); /* 行高21 = 12px×1.7590 = 纸高122 - 上下padding 32 */
if (need !== termShift) {
termShift = need;
termBody.style.transform = 'translateY(' + (-need) + 'px)';
}
return d;
}
/* 往提示符行里逐字敲(字符进内容 span行是 flex字符直接做 flex item 会吃掉空格;眼睛跟光标走) */
async function typeIntoLine(lineEl, text, perChar) {
var caret = lineEl.querySelector('.anim4-term-caret');
var content = lineEl.querySelector('.anim4-term-c');
for (var i = 0; i < text.length; i++) {
var c = document.createElement('span');
c.className = 'anim4-term-c';
c.textContent = text[i];
content.appendChild(c);
rescueUnits.push(c);
if (eyes && caret) {
var cr = caret.getBoundingClientRect();
eyes.lookAtPoint(cr.left, cr.top + cr.height / 2);
}
await rafWait(perChar);
}
}
/* ── 第三幕:笔甩飞(重力 + 弹跳 + 翻滚;旋转全程连续无跳变,落地即平躺) ── */
function penToss() {
return new Promise(function (resolve) {
penPos.classList.add('is-live');
pen.classList.add('is-free'); /* 旋转 origin 切到中心 */
/* vx=-110落点在时光机右侧约 x 320不与亭子重合 */
var px = penTx, py = penTy, vx = -110, vy = -560;
var G = 1050;
/* 预算总飞行时间 T与下方逐帧积分同一套常数/阈值) */
var T = (function () {
var apex = vy * vy / (2 * G) + (PEN_FLOOR_Y - py); /* 顶点超出地面的高度 */
var t = Math.abs(vy) / G + Math.sqrt(2 * apex / G);
var v = Math.sqrt(2 * G * apex);
while (v > 100) { v *= 0.42; t += 2 * v / G; }
return t;
})();
var SPIN = 720 - PEN_FLAT_ROT; /* 675.5°:起始 720° ≡ 手上持笔角 0°终点 44.5° 平躺 */
var acc = 0;
var last = performance.now();
function tick(now) {
var dt = Math.min(now - last, 50) / 1000;
last = now;
acc += dt;
vy += G * dt;
px += vx * dt;
py += vy * dt;
if (py >= PEN_FLOOR_Y) {
py = PEN_FLOOR_Y;
if (Math.abs(vy) > 100) {
vy = -vy * 0.42;
vx *= 0.55;
} else {
/* 落地即平躺(旋转早已连续收敛到 44.5°,归一化即可) */
pen.style.transform = 'rotate(' + PEN_FLAT_ROT + 'deg)';
setPen(px, py);
resolve();
return;
}
}
var u = Math.min(acc / T, 1);
var rot = PEN_FLAT_ROT + SPIN * (1 - u) * (1 - u); /* 连续翻滚,平滑减速到零 */
setPen(px, py);
pen.style.transform = 'rotate(' + rot.toFixed(1) + 'deg)';
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
});
}
/* ── 第四幕:倒带(时间线回卷 + 世界同步还原;笔躺在地上不受影响) ── */
function rewindWorld(ms) {
var rescueTotal = rescueUnits.length;
var termStateDone = false, termClassDone = false, linesZeroed = false;
var shattered = [false, false, false, false, false, false, false];
/* 对话记录在整个回溯期间慢慢向下滚动(滚动量 = 三条将碎气泡的占位总和),
碎裂均匀分布在起步p0.06,第 7 条尚完整可见)/ p0.325 / p0.58——空壳脱离滚动原地定住、
边下坠边淡出(淡出全程在视野内);收尾时移除空壳(占位消失)并同帧滚动归零,视觉无跳变 */
var scrollEls = Array.prototype.slice.call(bubblesEl.children);
var scrollTotal = 0;
[bubbleOf(4), bubbleOf(5), bubbleOf(6)].forEach(function (x) {
if (x) scrollTotal += x.getBoundingClientRect().height / curScale + BUBBLE_GAP;
});
return tween(ms, function (p, acc) {
var e = easeInOut(p);
/* 慢滚动:整段回溯推满 scrollTotal碎裂空壳 _detached 脱离队伍,原地定住湮灭) */
var sy = scrollTotal * e;
for (var sj = 0; sj < scrollEls.length; sj++) {
var se = scrollEls[sj];
if (se._detached) continue;
se.style.transform = 'translateY(' + sy.toFixed(1) + 'px)';
}
/* 后三条指令被时间退回:起步即碎第 7 条(尚在视野中央完整可见),随后等间隔碎 6、5——
碎裂必须发生在气泡完整可见时;等滚到底缘再碎,空壳淡出会被裁在视野外 */
if (!shattered[6] && p >= 0.06) { shattered[6] = true; shatterBubble(bubbleOf(6), sy); }
if (!shattered[5] && p >= 0.325) { shattered[5] = true; shatterBubble(bubbleOf(5), sy); }
if (!shattered[4] && p >= 0.58) { shattered[4] = true; shatterBubble(bubbleOf(4), sy); }
/* 亭子失重飘浮:正弦起伏 + 微旋 + 缓慢偏航 */
boothState.dy = BOOTH_FLOAT_DY + Math.sin(acc / 1000 * 2 * Math.PI / 1.15) * 5;
boothState.rot = -2 + Math.sin(acc / 1000 * 2 * Math.PI / 1.7) * 1;
boothState.sway = Math.sin(acc / 1000 * 2 * Math.PI / 2.3) * 2.2;
setBooth();
/* 抢救内容(两条 git 指令的每个字符 + 报错行 + 提示符行)按创建逆序逐一倒删,上滚同步回退 */
if (p >= 0.02) {
var rTarget = Math.round(rescueTotal * (1 - clamp01((p - 0.02) / 0.12)));
while (rescueUnits.length > rTarget) rescueUnits.pop().remove();
var need = Math.max(0, termBody.querySelectorAll('.anim4-term-line').length * 21 - 90);
if (need !== termShift) {
termShift = need;
termBody.style.transform = need ? 'translateY(' + (-need) + 'px)' : '';
}
}
/* 终端行态复位:光标恢复 + 输出/新提示行隐藏 */
if (!termStateDone && p >= 0.16) {
termStateDone = true;
termNew.querySelector('.anim4-term-caret').style.visibility = '';
termNew.classList.remove('is-on');
termOut.classList.remove('is-on');
termCaret.style.visibility = '';
}
/* 命令从右往左倒删 */
var targetLen = CMD.length - Math.round(CMD.length * clamp01((p - 0.14) / 0.18));
while (typedChars.length > targetLen) typedChars.pop().remove();
/* 翻纸前先把纸面行宽全抹掉(藏在终端层后面,看不见),稍后再逐一长回 */
if (!linesZeroed && p >= 0.30) {
linesZeroed = true;
lines.forEach(function (l) { l.style.width = '0%'; });
}
/* 终端变回纸(翻回斜铺) */
if (!termClassDone && p >= 0.32) {
termClassDone = true;
paper.classList.remove('is-term');
}
var mp = easeInOut(clamp01((p - 0.32) / 0.25));
var pose = {
rx: lerp(PAPER_FLAT.rx, PAPER_TILT.rx, mp),
rz: lerp(PAPER_FLAT.rz, PAPER_TILT.rz, mp),
ty: lerp(PAPER_FLAT.ty, PAPER_TILT.ty, mp)
};
paper._pose = pose;
setPaperTransform(pose.rx, pose.rz, pose.ty);
/* 纸面四行从前往后逐一长出(对应时间线回稳在第 4 条完成态) */
for (var k = 0; k < 4; k++) {
var lp = clamp01((p - (0.46 + k * 0.11)) / 0.13);
lines[k].style.width = (LINE_W[k] * easeOut(lp)) + '%';
}
}).then(function () {
boothState.sway = 0;
/* 收尾:碎裂空壳出列(占位消失量 = 滚动量),同帧清滚动 transform——视觉无跳变 */
for (var q = 0; q < shatteredShells.length; q++) { var sh = shatteredShells[q]; if (sh.parentNode) sh.parentNode.removeChild(sh); }
shatteredShells.length = 0;
for (var q2 = 0; q2 < scrollEls.length; q2++) scrollEls[q2].style.transform = '';
while (rescueUnits.length) rescueUnits.pop().remove(); /* 兜底清理 */
});
}
/* ── 主流程 ── */
async function play() {
/* 初始状态校正 */
blinksOn = true;
blinkLoop();
if (eyes) { eyes.setEyePath(EYE_IDLE); curEyePath = EYE_IDLE; eyes.lookAt(0, 2.5); }
/* ── 第一幕赶工4 条修改) ── */
for (var k = 0; k < 4; k++) {
await bubbleArrive(k);
await rafWait(120);
await writeLine(k);
morphEyes(window.AstrionAvatar.eyeScale(SLEEPY[k]), 420);
/* 困倦下沉与眼睛同周期渐变420ms不 await节奏不变——
原先无过渡瞬移,紧贴下一气泡出现前 140ms被读成「被新气泡挤下去」 */
var yFrom = agent.y;
tween(420, function (p) { agent.y = yFrom + 1.2 * easeInOut(p); setAgent(); });
await rafWait(140);
}
penToHand(); /* 笔回到手上 */
await rafWait(460);
/* ── 第二幕:闯祸 ── */
await bubbleArrive(4);
await rafWait(400);
await hopTo(AGENT_TERM_X, AGENT_GROUND_Y, 22, 340, true); /* 带笔跳下纸面 */
paperMorphTo(PAPER_FLAT, 520);
await rafWait(200);
paper.classList.add('is-term');
await rafWait(380);
await typeCommand();
await rafWait(180);
termCaret.style.visibility = 'hidden'; /* 运行后命令行光标消失 */
termOut.classList.add('is-on');
if (eyes) {
var outR = termOut.getBoundingClientRect();
eyes.lookAtPoint(outR.left + outR.width / 2, outR.top + outR.height / 2);
}
await rafWait(320);
termNew.classList.add('is-on');
await rafWait(500);
/* 快睡着了:眼睛快闭完 + 头微歪 */
morphEyes(window.AstrionAvatar.eyeScale(0.18), 600);
await tween(600, function (p) {
agent.rot = -4 * easeInOut(p);
setAgent();
penCarried();
});
/* ── 第三幕:惊吓 ── */
await bubbleArrive(5);
blinksOn = false;
if (eyes) {
morphEyes(EYE_IDLE, 120);
var br = bubbleOf(5).getBoundingClientRect(); /* 盯着刚浮出来的第 6 条指令气泡 */
eyes.lookAtPoint(br.left + br.width / 2, br.top + br.height / 2);
}
agent.rot = 0;
setAgent();
penCarried();
await rafWait(650); /* 读懂了… */
/* 猛跳 + 瞪眼 + 左右看 + 左右晃:全部同时发生 */
morphEyes(window.AstrionAvatar.eyeScale(1.35), 90);
var scare = tween(940, function (p, acc) {
var jumpP = Math.min(acc / 260, 1); /* 跳一下 */
agent.y = AGENT_GROUND_Y - Math.sin(jumpP * Math.PI) * 12;
var damp = 1 - easeInOut(clamp01((acc - 600) / 320)); /* 末段振幅收敛到 0结束时不会瞬变拍平 */
var wob = Math.sin(acc / 1000 * 24) * damp; /* 左右晃 */
agent.rot = wob * 2.4;
agent.x = AGENT_TERM_X + wob * 2.5;
setAgent();
/* 眼睛 130ms 换一次方向 */
if (eyes) eyes.lookAt(Math.floor(acc / 130) % 2 ? 6 : -6, -1);
});
/* 晃完定格:看向左上方(笔被甩飞出去的方向),而不是停在右侧 */
scare.then(function () { if (eyes) eyes.lookAt(-6, -3); });
await Promise.all([scare, penToss()]);
agent.x = AGENT_TERM_X; agent.y = AGENT_GROUND_Y; agent.rot = 0;
setAgent();
await rafWait(380);
/* ── 抢救:连敲两条 git 指令想把 cache/ 救回来,却发现它根本没被 git 追踪 ── */
if (eyes) { morphEyes(window.AstrionAvatar.eyeScale(1), 260); eyes.lookAt(2, 1.5); }
await rafWait(320);
await typeIntoLine(termNew, RESCUE_1, 44); /* git restore cache/ */
await rafWait(160);
termNew.querySelector('.anim4-term-caret').style.visibility = 'hidden'; /* 回车运行 */
var err1 = addTermLine('anim4-term-line anim4-term-out is-on', RESCUE_ERR);
if (eyes) { var e1r = err1.getBoundingClientRect(); eyes.lookAtPoint(e1r.left + e1r.width / 2, e1r.top + e1r.height / 2); }
await rafWait(560); /* 愣住:没被追踪 */
/* 不甘心,再试一条 */
var prompt2 = addTermLine('anim4-term-line anim4-term-new is-on', '<span class="anim4-term-p">&gt;</span><span class="anim4-term-c"></span><i class="anim4-term-caret"></i>');
await rafWait(220);
await typeIntoLine(prompt2, RESCUE_2, 38); /* git checkout cache/ */
await rafWait(140);
prompt2.querySelector('.anim4-term-caret').style.visibility = 'hidden';
var err2 = addTermLine('anim4-term-line anim4-term-out is-on', RESCUE_ERR);
addTermLine('anim4-term-line anim4-term-new is-on', '<span class="anim4-term-p">&gt;</span><span class="anim4-term-c"></span><i class="anim4-term-caret"></i>'); /* 最终空提示符 */
if (eyes) { var e2r = err2.getBoundingClientRect(); eyes.lookAtPoint(e2r.left + e2r.width / 2, e2r.top + e2r.height / 2); }
await rafWait(520);
/* 救不回来,用户气炸了追加一条骂 */
await bubbleArrive(6);
if (eyes) {
var br6 = bubbleOf(6).getBoundingClientRect(); /* 看一眼骂自己的气泡 */
eyes.lookAtPoint(br6.left + br6.width / 2, br6.top + br6.height / 2);
}
await rafWait(700); /* 读懂了…… */
/* 绝望拉拉 */
morphEyes(window.AstrionAvatar.eyeScale(0.55), 380);
await tween(380, function (p) { agent.rot = -5 * easeInOut(p); setAgent(); });
await rafWait(300);
/* 被时光机到场的动静惊动:回神 */
morphEyes(window.AstrionAvatar.eyeScale(1), 200);
await tween(200, function (p) { agent.rot = -5 * (1 - easeInOut(p)); setAgent(); });
/* ── 第四幕:穿越 ── */
boothState.op = 1;
setBooth();
await tween(640, function (p) { /* 从上方落下(加速) */
boothState.dy = lerp(BOOTH_FLY_Y, 0, easeIn(p));
setBooth();
});
await tween(170, function (p) { boothState.dy = -13 * Math.sin(p * Math.PI * 0.5); setBooth(); });
await tween(140, function (p) { boothState.dy = -13 * Math.cos(p * Math.PI * 0.5); setBooth(); });
await tween(100, function (p) { boothState.dy = -4 * Math.sin(p * Math.PI); setBooth(); });
boothState.dy = 0;
setBooth();
calibrateInner(); /* 落地态标定门洞(无倾斜无偏航,投影最准) */
await rafWait(240);
await doorTween(1, 400); /* 双开门 */
await rafWait(160);
/* 团子步走到门口正前方 */
await walkTo(doorGeom.cxStage - 35, 2600);
/* 走进门里(此时图层仍在时光机之上,能看见他走进门口):微缩表示踏入纵深,摇摆渐停 */
await tween(300, function (p, acc) {
var e = easeInOut(p);
agent.sx = agent.sy = lerp(1, 0.94, e);
agent.rot = Math.sin(acc / 1000 * 9) * 6 * (1 - p);
setAgent();
});
agent.rot = 0;
setAgent();
/* 关门瞬间换层:沉到前壳之下,门板在他面前关拢、把他挡在亭内 */
reparentKeepVisual(boothInner);
await doorTween(0, 350);
await rafWait(200);
/* 浮空 + 微旋,开始穿越;被退回的指令气泡在倒带时逐字湮灭 */
await tween(850, function (p) {
var e = easeInOut(p);
boothState.dy = BOOTH_FLOAT_DY * e;
boothState.rot = -2 * e;
setBooth();
});
await rewindWorld(2400);
/* 落地 */
await tween(600, function (p) {
var e = easeIn(p);
boothState.dy = BOOTH_FLOAT_DY * (1 - e);
boothState.rot = -2 * (1 - e);
setBooth();
});
await tween(150, function (p) { boothState.dy = -8 * Math.sin(p * Math.PI); setBooth(); });
boothState.dy = 0;
boothState.rot = 0;
setBooth();
calibrateInner(); /* 落地后重标定门洞 */
await rafWait(220);
/* ── 第五幕:归来 ── */
/* 开门(前壳在上,门板向前掀开):他就站在亭内门口,眼睛左右看看 */
await doorTween(1, 350);
if (eyes) {
eyes.setEyePath(EYE_IDLE);
curEyePath = EYE_IDLE;
eyes.lookAt(-5, 0);
}
await rafWait(300);
if (eyes) eyes.lookAt(5, 0);
await rafWait(320);
if (eyes) eyes.lookAt(0, 1);
await rafWait(180);
/* 迈出门来:换回最上层(在前壳之上),边向外迈一步边恢复体型(动作掩护换层瞬间) */
reparentKeepVisual(stage);
var outX = agent.x;
await tween(340, function (p, acc) {
var e = easeInOut(p);
agent.x = lerp(outX, outX + 30, e);
agent.sx = agent.sy = lerp(0.94, 1, e);
var wob = e * Math.PI * 2; /* 向外迈一步 */
agent.rot = Math.sin(wob) * 5;
agent.y = AGENT_GROUND_Y - Math.abs(Math.sin(wob)) * 3;
setAgent();
});
agent.rot = 0;
agent.y = AGENT_GROUND_Y;
setAgent();
/* 走向地上的笔(门保持开着,等他彻底走开) */
var penCX = penTx + 20;
await walkTo(penCX - 36, 800); /* 手x+52落在笔上方 */
await doorTween(0, 350); /* 完全走出去后才关门 */
if (eyes) eyes.lookAt(0, 3);
await tween(200, function (p) { /* 蹲下 */
var k = Math.sin(p * Math.PI * 0.5);
agent.sy = 1 - 0.16 * k;
agent.sx = 1 + 0.1 * k;
agent.y = AGENT_GROUND_Y + 11 * k; /* 脚下沉补偿:脚贴地 */
setAgent();
});
pen.classList.remove('is-free');
pen.classList.add('is-returning'); /* transition 平滑回正 + origin 保持中心 */
pen.style.transform = 'rotate(' + PEN_FLAT_ROT + 'deg)';
void pen.offsetWidth; /* 定格起始态再触发过渡 */
pen.style.transform = 'rotate(0deg)';
/* 位置滑翔到手持位:终点直接按「旋转 0° 时」的携带坐标算。
不能用 nibTo 实测——此刻笔正在回正旋转中,实测到的是平躺态笔尖,
滑翔终点会差一截,等 penCarried 接管时就瞬移跳一下 */
penPos.classList.remove('is-live');
setPen(agent.x + HAND_DX - PEN_NIB_DX, agent.y + HAND_DY - PEN_NIB_DY);
await rafWait(400); /* 等滑翔+回正完成 */
await tween(200, function (p) { /* 起身(笔钉在手上随身体一起抬起) */
var k = 1 - Math.sin(p * Math.PI * 0.5);
agent.sy = 0.84 + 0.16 * (1 - k);
agent.sx = 1.1 - 0.1 * (1 - k);
agent.y = AGENT_GROUND_Y + 11 * k;
setAgent();
penCarried();
});
agent.sy = 1; agent.sx = 1; agent.y = AGENT_GROUND_Y;
setAgent();
penCarried();
await rafWait(140);
setTimeout(function () { pen.classList.remove('is-returning'); }, 420); /* 回正完成后 origin 回笔尖 */
/* 带笔走回右侧,跳回纸上工作位 */
await walkTo(AGENT_HOME.x, 1500, true);
if (eyes) eyes.lookAt(0, 2);
await hopTo(AGENT_HOME.x, AGENT_HOME.y, 60, 560, true);
if (eyes) eyes.lookAt(0, 2.5);
/* 时光机不离场:落地后留在原地,成为舞台常驻道具 */
await rafWait(600);
/* ── 谢幕:定格在第 4 条写完的现场(时间线/纸面/持笔都停在这),呼吸感保留:
眨眼恢复 + 解除手动注视,眼睛交还给访客的鼠标跟踪 ── */
penToHand();
if (eyes) {
morphEyes(EYE_IDLE, 420);
eyes.setEyePath(EYE_IDLE);
curEyePath = EYE_IDLE;
eyes.releaseLook();
}
blinksOn = true;
blinkLoop();
return;
}
/* 初始就位 */
updateScale();
setPaperTransform(PAPER_TILT.rx, PAPER_TILT.rz, PAPER_TILT.ty);
paper._pose = PAPER_TILT;
setAgent();
penPos.classList.add('is-live');
nibTo(AGENT_HOME.x + HAND_DX, AGENT_HOME.y + HAND_DY, stageRect());
void penPos.offsetWidth;
penPos.classList.remove('is-live');
doorSet(0);
setBooth();
calibrateInner(); /* 初始标定一次(此时 booth 虽在上方藏点,但门洞与 booth 的相对几何不变) */
/* 进入视口才播放(首次相交后即启动并断开) */
var started = false;
var io = new IntersectionObserver(function (entries) {
if (!started && entries[0].isIntersecting) {
started = true;
io.disconnect();
play();
}
}, { threshold: 0.3 });
io.observe(container);
}
return { init: init, setLocale: setLocale };
})();