1162 lines
58 KiB
JavaScript
1162 lines
58 KiB
JavaScript
/* 版本控制动画 —— 可复用模块(主页 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 =
|
||
'<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">></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">></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 ±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', '<span class="anim4-term-p">></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">></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 };
|
||
})();
|