/* 版本控制动画 —— 可复用模块(主页 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-CN);T(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 =
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
>
' +
'
' + CMD_OUT + '
' +
'
>
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
'
' +
/* 整机 2D 矢量化:所有面手算透视投影后屏幕空间绘制(根治 3D 面片纹理重采样锯齿)。
三层:后层 svg z1(左外墙 + clip 到门洞投影的内景四面)< 智能体挂载层 z2
< 前层 svg z3(门槛/双梃/双开门/屋顶/尖顶)——关门门板即挡住他,同原前后壳语义 */
'
' +
'
' +
'
' +
'
' +
'
' +
'
';
/* 元素查找一律限定在自身容器内:主页上 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 ±48,y ±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/transform(rAF 可见时间,后台冻结恢复不跳变)——
margin 动画自然推拉其余气泡,无需 FLIP;绝不 tween height(border-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';
}
}
/* 重挂载(舞台 ↔ 亭内景层)且保持屏幕视觉位置不变:
两边都是纯 2D,getBoundingClientRect 实测中心对齐即可(无透视无旋转) */
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.75;90 = 纸高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', '>');
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', '>'); /* 最终空提示符 */
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 };
})();