/* 版本控制动画 —— 可复用模块(主页 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 }; })();