astrion-website/demo/site-assets/anim3-demo.js

553 lines
25 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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

/* 修改记录动画 —— 智能体持笔在 3D 倾斜纸上改写内容,
* 笔尖始终对准线条进度点(逐帧同步),笔以笔尖为圆心摇摆;
* 纸摊平后绿行抽出展开为红绿 diff。自动播放点击场景重播。
*/
(function () {
'use strict';
var stage = document.getElementById('anim3Stage');
var paper = document.getElementById('paper');
var penPos = document.getElementById('penPos');
var pen = document.getElementById('writerPen');
var boxBack = document.getElementById('boxBack');
var boxFront = document.getElementById('boxFront');
var diffView = document.getElementById('diffView');
var lines = Array.prototype.slice.call(paper.querySelectorAll('.pline'));
var dlines = Array.prototype.slice.call(diffView.querySelectorAll('.dline'));
// 挂载六边形眼睛(眨眼 + 追踪)
if (window.AstrionAvatar) {
AstrionAvatar.mount(document.getElementById('writerAvatar'), { mode: 'idle', blink: true, track: true });
}
var gen = 0;
/* 笔尖/橡皮的精确位置不再手调像素:
* SVG 内放了两个隐形参考点penNibRef / penEraserRef
* 运行时 getBoundingClientRect 实测其渲染位置做补偿——
* 无论笔怎么旋转/摇摆,作用点都精确钉在目标上。 */
var nibRef = document.getElementById('penNibRef');
var eraserRef = document.getElementById('penEraserRef');
var penTx = 0, penTy = 0;
/* 笔的初始位(被智能体拿着):笔尖所在点,笔身斜跨六边形 */
var PEN_HOME = { x: 456, y: 94 };
function sleep(ms) {
return new Promise(function (r) { setTimeout(r, ms); });
}
async function step(ms, myGen) {
await sleep(ms);
if (myGen !== gen) throw new Error('cancelled');
}
function el(tag, cls) {
var e = document.createElement(tag);
if (cls) e.className = cls;
return e;
}
function stageRect() { return stage.getBoundingClientRect(); }
/* 缓动 */
function easeOut(p) { return 1 - (1 - p) * (1 - p); }
function easeInOut(p) { return p < 0.5 ? 2 * p * p : 1 - 2 * (1 - p) * (1 - p); }
/* rAF 逐帧驱动(返回 false/取消时提前结束) */
function tween(ms, fn, myGen) {
return new Promise(function (resolve) {
var t0 = performance.now();
function tick(now) {
if (myGen !== gen) { resolve(); return; }
var p = Math.min((now - t0) / ms, 1);
fn(p);
if (p >= 1) { resolve(); return; }
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
});
}
function setPen(x, y) {
penTx = x; penTy = y;
penPos.style.transform = 'translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px)';
}
/* 手动微调css px数据对准后仍有轻微视觉偏差时在这里调用户肉眼验收 */
var NIB_TWEAK = { x: 0, y: -1.8 };
var ERASER_TWEAK = { x: 0, y: 0 };
/* 把参考点精确移到舞台坐标 (x, y):实测当前位置,差值补偿到外层 translate一次收敛 */
function refTo(refEl, x, y, sr) {
var r = refEl.getBoundingClientRect();
var cx = r.left + r.width / 2 - sr.left;
var cy = r.top + r.height / 2 - sr.top;
setPen(penTx + (x - cx), penTy + (y - cy));
}
function nibTo(x, y, sr) { refTo(nibRef, x + NIB_TWEAK.x, y + NIB_TWEAK.y, sr); }
function eraserTo(x, y, sr) { refTo(eraserRef, x + ERASER_TWEAK.x, y + ERASER_TWEAK.y, sr); }
/* 行右端进度点的舞台坐标:倾斜线的包围盒中心≠真实右端点,
* 用线内右端锚点(.pline-end的实测位置兼容 3D 透视 + rotZ 倾斜 */
function lineTip(line, sr) {
var r = line.querySelector('.pline-end').getBoundingClientRect();
return { x: r.left + r.width / 2 - sr.left, y: r.top + r.height / 2 - sr.top };
}
function nibToLineTip(line, sr) {
var t = lineTip(line, sr);
nibTo(t.x, t.y, sr);
}
/* 三轮内容Dockerfile / Python / TS不全是一种代码
纸上横线长度 ≈ 对应行字符数(~2.3%/字符)——线长与内容长短一致;
file/tone收盒前渐变覆盖的文件卡红/黄/绿,露出一截即可分辨)。 */
var ROUNDS = [
{
file: 'Docker',
paperW: [34, 26, 59, 44],
edits: [
{ newW: 26, del: 'EXPOSE 8080', add: 'EXPOSE 8091' },
/* 最长行 26 字符≈237px 宽diff 左缘保持在 stage 内(>15px否则左缘描边被 stage 裁掉 */
{ newW: 53, del: 'CMD ["python", "server.py"]', add: 'CMD ["python", "main.py"]' }
]
},
{
file: 'api.py',
paperW: [56, 35, 46, 30],
edits: [
{ newW: 35, del: 'MAX_RETRIES = 3', add: 'MAX_RETRIES = 5' },
{ newW: 46, del: 'request_timeout = 30', add: 'request_timeout = 60' }
]
},
{
file: 'theme.ts',
paperW: [46, 30, 66, 58],
edits: [
{ newW: 37, del: "theme: 'dark'", add: "theme: 'classic'" },
{ newW: 66, del: 'fontSize: 13, lineHeight: 1.4', add: 'fontSize: 14, lineHeight: 1.6' }
]
}
];
/* 盒内纸堆叠透视(按盒子 SVG 侧边斜率 dx/dy=0.5 推导:每向深处 1px 两侧共收 1px
每往里一张纸,顶边抬高 ~10px、宽度按侧边角度同步收窄、只露出顶部一截。
topY/bottomY 为 stage 坐标wRatio 为相对盒子图标宽的比例。 */
/* 等比透视:三张文件本身差不多高,随深度每层缩 ~8%(与宽度收敛同比率);
底边都落在盒口附近194/183/172——每张收进去的瞬间都是真的插进盒子不悬空
顶边各露 5px底边落差 11px - 高度缩 6px ≈ 5px */
/* 等比透视:三张文件本身差不多高,随深度略缩;底边都落在盒口附近——
每张收进去的瞬间都是真的插进盒子,不悬空;顶边各露 5px。
(坐标基于盒子下移 50px 后:盒口 y=224 */
var FILED_SPECS = [
{ topY: 182, bottomY: 234, wRatio: 0.66 }, /* 最前:高 52最宽、最低、贴前壁 */
{ topY: 177, bottomY: 223, wRatio: 0.608 }, /* 中间:高 46 */
{ topY: 172, bottomY: 212, wRatio: 0.556 } /* 最里:高 40底边在盒口线上 */
];
/* diff 行拆成单字符(初始透明占位),擦/写时字母逐个从笔处飘入落位。
落位的 ghost 会转正替换占位符成为流内正式字符reset 时重建占位 */
var dlineTexts = [];
function setDiffTexts(r) {
var e = ROUNDS[r].edits;
dlineTexts = [e[0].del, e[0].add, e[1].del, e[1].add];
}
setDiffTexts(0);
var dlineChars = dlines.map(function () { return []; });
function buildDlineChars() {
dlines.forEach(function (d, di) {
var t = d.querySelector('.dtext');
t.textContent = '';
dlineChars[di] = [];
for (var i = 0; i < dlineTexts[di].length; i++) {
var c = el('span', 'dch');
/* 空格用不换行空格:普通空格 span 会塌缩成 0 宽/高度异常,落点歪斜 */
c.textContent = dlineTexts[di][i] === ' ' ? ' ' : dlineTexts[di][i];
t.appendChild(c);
dlineChars[di].push(c);
}
});
}
buildDlineChars();
/* 一个字母从舞台 (x0,y0)(笔的擦/写位置)飘到 diff 行目标字符位。
落位后转正替换占位符成为流内正式字符——genie 收纸时文字随纸一起变形;
首个落位字符触发行容器(红/绿底色)淡入:底色与文字同步,不先于文字出现 */
function flyChar(x0, y0, chSpan, sr, myGen) {
var to = chSpan.getBoundingClientRect();
var g = el('span', 'dch-ghost');
g.textContent = chSpan.textContent;
g.style.color = getComputedStyle(chSpan).color;
g.style.transform = 'translate(' + (x0 - 3).toFixed(1) + 'px,' + (y0 - 8).toFixed(1) + 'px)';
stage.appendChild(g);
void g.offsetWidth; /* 起始位定格后再设终点,保证过渡触发 */
g.style.transform = 'translate(' + (to.left - sr.left).toFixed(1) + 'px,' + (to.top - sr.top).toFixed(1) + 'px)';
g.addEventListener('transitionend', function handler() {
g.removeEventListener('transitionend', handler);
if (myGen !== gen) { g.parentNode && g.parentNode.removeChild(g); return; }
/* 飞行过渡真正结束后转正:清掉行内样式、换成流内字符 class、替换占位符同帧完成无跳变 */
g.style.cssText = '';
g.className = 'dch is-lit';
var row = chSpan.parentNode; /* .dtext */
var dline = row.parentNode; /* .dline */
row.replaceChild(g, chSpan);
/* 第一个字符到位,该行的红绿背景与 +/- 符号即浮现(后续字符陆续落位在已有底色的行里) */
dline.classList.add('is-shown');
});
}
function applyWidths() {
lines.forEach(function (l) {
l.style.width = l.dataset.w + '%';
});
}
function reset() {
gen += 1;
pen.classList.remove('is-writing');
penPos.classList.remove('is-live');
lines.forEach(function (l) {
l.classList.remove('is-new');
l.style.opacity = '';
});
lines.forEach(function (l, i) { l.dataset.w = ROUNDS[0].paperW[i]; });
applyWidths();
setDiffTexts(0);
diffView.classList.add('no-fade'); /* 重播重置同样禁用过渡,防红绿块淡出闪现 */
dlines.forEach(function (d) { d.classList.remove('is-shown'); });
Array.prototype.slice.call(stage.querySelectorAll('.dch-ghost')).forEach(function (g) { g.parentNode.removeChild(g); });
buildDlineChars(); /* 落位字符已转正为流内文本,重播前重建透明占位 */
Array.prototype.slice.call(stage.querySelectorAll('.file-flight')).forEach(function (g) { g.parentNode.removeChild(g); });
diffView.style.clipPath = '';
diffView.style.transform = '';
diffView.style.transformOrigin = '';
diffView.style.opacity = '';
diffView.style.visibility = '';
diffView.classList.remove('is-sheet');
void diffView.offsetWidth;
diffView.classList.remove('no-fade');
/* 收件箱始终显示,不在重置时隐藏 */
pen.classList.remove('is-erasing');
pen.style.transform = '';
penPos.classList.add('is-live'); /* 重置瞬移,不起飞行动画 */
nibTo(PEN_HOME.x, PEN_HOME.y, stageRect());
void penPos.offsetWidth; /* 强制样式计算:让 transform 在 transition:none 下定格,避免移除 is-live 后触发回退过渡 */
penPos.classList.remove('is-live');
}
/* 改写一行:笔尖飞到旧行末端 → 翻转 180° 露出橡皮 → 橡皮小幅度晃动擦掉(线缩短,橡皮跟随)→ 翻回行首 → 绿行从笔尖长出 */
async function rewriteLine(line, newW, delDline, addDline, myGen) {
var sr = stageRect();
var oldW = parseFloat(line.dataset.w);
// 1) 飞到该行旧内容末端(笔尖对准线右端,过渡飞行)
penPos.classList.remove('is-live');
nibToLineTip(line, sr);
await step(430, myGen);
// 2) 倒笔先平滑平移换端笔尖→橡皮JS tween 驱动,无瞬移),再绕橡皮翻转 180°
// (同步块内连续 setPen 不触发渲染,借此算出平移的精确起止 translate
pen.classList.add('is-erasing');
penPos.classList.add('is-live');
var t0 = lineTip(line, sr);
var fromX = penTx, fromY = penTy;
eraserTo(t0.x, t0.y, sr); /* 算出橡皮贴线的目标 translate */
var toX = penTx, toY = penTy;
setPen(fromX, fromY); /* 设回起点,同一同步块内不渲染 */
await tween(280, function (p) {
var e = easeInOut(p);
setPen(fromX + (toX - fromX) * e, fromY + (toY - fromY) * e);
}, myGen);
// 3) 翻转 180°绕橡皮平滑转动期间橡皮始终钉在线右端
await tween(300, function (p) {
pen.style.transform = 'rotate(' + (easeInOut(p) * 180).toFixed(1) + 'deg)';
var t = lineTip(line, sr);
eraserTo(t.x, t.y, sr);
}, myGen);
// 4) 擦除:线缩短,橡皮跟随右端;笔小幅晃动;字母随擦除从橡皮处逐个飘向左侧红行(从右往左)
// 时长与行长成正比(等速擦除);行容器(红底)随首个落位字符出现
var delChars = dlineChars[dlines.indexOf(delDline)];
var dN = delChars.length;
var nextDel = dN - 1;
var eraseMs = Math.round(oldW * 7);
await tween(eraseMs, function (p) {
line.style.width = (oldW * (1 - easeInOut(p))) + '%';
var wob = Math.sin(p * Math.PI * 4) * 7; // 两个来回±7°
pen.style.transform = 'rotate(' + (180 + wob).toFixed(1) + 'deg)';
var t = lineTip(line, sr);
eraserTo(t.x, t.y, sr);
while (nextDel >= 0 && p >= (dN - nextDel) / (dN + 1)) {
flyChar(t.x, t.y, delChars[nextDel], sr, myGen);
nextDel--;
}
}, myGen);
// 5) 翻回 0°origin 仍在笔尾,橡皮钉在行首(线宽已 0右端即行首
// 转回 0° 后移除 is-erasingorigin 切回笔尖无跳变
var home = lineTip(line, sr);
await tween(280, function (p) {
pen.style.transform = 'rotate(' + (180 * (1 - easeInOut(p))).toFixed(1) + 'deg)';
eraserTo(home.x, home.y, sr);
}, myGen);
pen.classList.remove('is-erasing');
pen.style.transform = '';
// 6) 写上绿色新内容笔尖跟随右端is-writing 摇摆;字母随书写从笔尖逐个飘向左侧绿行(从左往右)
penPos.classList.remove('is-live');
nibTo(home.x, home.y, sr);
await step(320, myGen);
/* 写入行不变色(修改记录靠 diff 区表达,纸面行保持同色) */
pen.classList.add('is-writing');
penPos.classList.add('is-live');
var addChars = dlineChars[dlines.indexOf(addDline)];
var aN = addChars.length;
var nextAdd = 0;
var writeMs = Math.round(newW * 7); // 时长与行长成正比(等速书写)
await tween(writeMs, function (p) {
line.style.width = (newW * easeOut(p)) + '%';
nibToLineTip(line, sr);
while (nextAdd < aN && p >= (nextAdd + 0.5) / aN) {
var t2 = lineTip(line, sr);
flyChar(t2.x, t2.y, addChars[nextAdd], sr, myGen);
nextAdd++;
}
}, myGen);
pen.classList.remove('is-writing');
}
/* genie 收盒 · 裁切式:文件卡片始终是同一个 DOM 元素,不切片、不缩放内容——
每帧把 S 曲线轮廓(底部先收窄、侧边弧线、顶部后动)算成 clip-path 多边形,
裁掉轮廓外的部分(裁切不压缩,内容全程原样清晰);
body底色+边框+文字)每帧定位到轮廓包围盒,文字随收窄矢量缩小字号;
终态轮廓恰好是插在盒口的小矩形,元素原地成为堆叠的一张,无替换无跳变。 */
async function genieIntoBox(myGen, r, flight) {
var shape = flight.querySelector('.file-flight-shape');
var name = flight.querySelector('.file-cover-name');
var stat = flight.querySelector('.file-cover-stat');
var sr = stageRect();
var br = boxBack.getBoundingClientRect();
var px = +flight.dataset.px, py = +flight.dataset.py;
var W = +flight.dataset.w, H = +flight.dataset.h;
/* 盒口中心stage 坐标):图标 y=12/24 处为盒口前缘线 */
var mouthX = br.left + br.width / 2 - sr.left;
/* 收盒顺序 3→2→1第一轮收最里面最后一轮收最前后收的挡先收的 */
var spec = FILED_SPECS[FILED_SPECS.length - 1 - r];
var sinkY = spec.bottomY - (py + H);
var centerDx = mouthX - (px + W / 2);
var minS = (br.width * spec.wRatio) / W;
var compressH = spec.topY - py - sinkY;
flight.style.height = (H + sinkY + 20) + 'px'; /* SVG 视口加高覆盖下降路径 */
/* 交错相位运动(同一套数学):底部先动先快,顶部延迟——
每帧把轮廓直接画成 pathfill 纸色 + stroke 描边跟随 S 曲线) */
/* 侧边采样 72 段≈1.6px/段折线足够密S 曲线侧边平滑无锯齿 */
var N = 72;
var totalMs = 1500;
var sliceMs = 950;
var maxDelay = totalMs - sliceMs;
var lastBox = { x: 0, y: 0, w: W, h: H };
var RR = 3.5; /* 运动期四角圆角半径(与封面/终态 roundedRectD 一致,无圆直跳变) */
await tween(totalMs, function (p) {
var now = p * totalMs;
var L = [], R = [];
var minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9;
for (var i = 0; i < N; i++) {
var v = (i + 0.5) / N;
var start = (1 - v) * maxDelay;
var q = Math.min(Math.max((now - start) / sliceMs, 0), 1);
var e = easeInOut(q);
var sx = 1 - (1 - minS) * e;
var ty = (sinkY + (1 - v) * compressH) * e;
var tx = centerDx * e;
var lw = W * sx;
var lx = (W - lw) / 2 + tx, rx = lx + lw;
var yt = i * H / N + ty, yb = (i + 1) * H / N + ty;
L.push([lx, yt], [lx, yb]);
R.push([rx, yt], [rx, yb]);
if (lx < minX) minX = lx;
if (rx > maxX) maxX = rx;
if (yt < minY) minY = yt;
if (yb > maxY) maxY = yb;
}
/* 拼圆角多边形:顶/底边内缩 RR四角 Q 贝塞尔过渡(控制点=角点);
侧边仍是 72 段平滑折线 */
var f = function (n) { return n.toFixed(1); };
var lt = L[0], lb0 = L[1], rt = R[0], rb0 = R[1];
var rbN = R[(N - 1) * 2 + 1], lbN = L[(N - 1) * 2 + 1];
var d = 'M' + f(lt[0] + RR) + ',' + f(lt[1]);
d += ' L' + f(rt[0] - RR) + ',' + f(rt[1]);
d += ' Q' + f(rt[0]) + ',' + f(rt[1]) + ' ' + f(rb0[0]) + ',' + f(rb0[1]); /* 右上 */
for (var i = 1; i < N - 1; i++) {
d += ' L' + f(R[i * 2][0]) + ',' + f(R[i * 2][1]);
d += ' L' + f(R[i * 2 + 1][0]) + ',' + f(R[i * 2 + 1][1]);
}
d += ' Q' + f(rbN[0]) + ',' + f(rbN[1]) + ' ' + f(rbN[0] - RR) + ',' + f(rbN[1]); /* 右下 */
d += ' L' + f(lbN[0] + RR) + ',' + f(lbN[1]); /* 底边 */
d += ' Q' + f(lbN[0]) + ',' + f(lbN[1]) + ' ' + f(L[(N - 1) * 2][0]) + ',' + f(L[(N - 1) * 2][1]); /* 左下 */
for (var j = N - 2; j >= 1; j--) {
d += ' L' + f(L[j * 2 + 1][0]) + ',' + f(L[j * 2 + 1][1]);
d += ' L' + f(L[j * 2][0]) + ',' + f(L[j * 2][1]);
}
d += ' L' + f(lb0[0]) + ',' + f(lb0[1]); /* 段0左下 */
d += ' Q' + f(lt[0]) + ',' + f(lt[1]) + ' ' + f(lt[0] + RR) + ',' + f(lt[1]) + ' Z'; /* 左上闭合 */
shape.setAttribute('d', d);
/* 文字随轮廓中心移动、矢量缩小字号。
font-size 必须内联 stylepresentation attribute 优先级低于 CSS 规则,会被压住 */
var cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
var sc = (maxX - minX) / W;
/* 字号双保险随收窄等比缩且不超过卡片宽能放下的上限mono 字宽 ≈0.62em */
var fsFit = (maxX - minX) * 0.88 / (name.textContent.length * 0.62);
var fs1 = Math.min(Math.max(15 * sc, 7), fsFit);
var fs2 = Math.max(11 * sc, 7);
name.setAttribute('x', cx);
name.setAttribute('y', cy - 2);
name.style.fontSize = fs1.toFixed(1) + 'px';
stat.setAttribute('x', cx);
stat.setAttribute('y', cy + fs2 + 2);
stat.style.fontSize = fs2.toFixed(1) + 'px';
lastBox.x = minX; lastBox.y = minY; lastBox.w = maxX - minX; lastBox.h = maxY - minY;
}, myGen);
/* 终态:换回圆角矩形 path描边+圆角完整),元素留在盒口成为堆叠的一张 */
shape.setAttribute('d', roundedRectD(lastBox.x, lastBox.y, lastBox.w, lastBox.h, 3.5));
}
/* SVG 工具SVG 元素必须用 createElementNS 创建createElement 建的不渲染) */
var SVG_NS = 'http://www.w3.org/2000/svg';
function svgEl(tag, cls) {
var e = document.createElementNS(SVG_NS, tag);
if (cls) e.setAttribute('class', cls);
return e;
}
/* 圆角矩形 path初始封面与终态卡片用genie 期间为直边多边形) */
function roundedRectD(x, y, w, h, r) {
return 'M' + (x + r) + ',' + y +
' L' + (x + w - r) + ',' + y +
' A' + r + ',' + r + ' 0 0 1 ' + (x + w) + ',' + (y + r) +
' L' + (x + w) + ',' + (y + h - r) +
' A' + r + ',' + r + ' 0 0 1 ' + (x + w - r) + ',' + (y + h) +
' L' + (x + r) + ',' + (y + h) +
' A' + r + ',' + r + ' 0 0 1 ' + x + ',' + (y + h - r) +
' L' + x + ',' + (y + r) +
' A' + r + ',' + r + ' 0 0 1 ' + (x + r) + ',' + y + ' Z';
}
/* 收盒前文件卡片SVGpath 纸色+描边text 文件名+统计)渐变出现,
diff 内容直接消失;卡片是 SVG 单元素——描边跟随 path 轮廓S 曲线斜边也有) */
async function showFileCover(r, myGen) {
var sr = stageRect();
var pr = diffView.getBoundingClientRect();
var px = pr.left - sr.left, py = pr.top - sr.top;
var W = pr.width, H = pr.height;
var flight = svgEl('svg', 'file-flight');
flight.style.left = px + 'px';
flight.style.top = py + 'px';
flight.style.width = W + 'px';
flight.style.height = H + 'px';
/* 创建即设层级(先收最里 21后收在前封面期就必须在 diffView(z20) 之上 */
flight.style.zIndex = String(21 + r);
flight.dataset.px = px; flight.dataset.py = py;
flight.dataset.w = W; flight.dataset.h = H;
var shape = svgEl('path', 'file-flight-shape');
shape.setAttribute('d', roundedRectD(0, 0, W, H, 3.5));
var name = svgEl('text', 'file-cover-name');
name.setAttribute('x', W / 2);
name.setAttribute('y', H / 2 - 3);
name.setAttribute('text-anchor', 'middle');
name.textContent = ROUNDS[r].file;
var stat = svgEl('text', 'file-cover-stat');
stat.setAttribute('x', W / 2);
stat.setAttribute('y', H / 2 + 14);
stat.setAttribute('text-anchor', 'middle');
var add = svgEl('tspan', 'add'); add.textContent = '+2';
var del = svgEl('tspan', 'del'); del.textContent = ' -2';
stat.appendChild(add);
stat.appendChild(del);
flight.appendChild(shape);
flight.appendChild(name);
flight.appendChild(stat);
stage.appendChild(flight);
getComputedStyle(flight).opacity; /* getBoundingClientRect 只算几何不重算样式getComputedStyle 才强制样式计算,定格 opacity 0 */
flight.classList.add('is-on');
await step(520, myGen); /* 等封面渐变动画完全结束 */
diffView.style.visibility = 'hidden'; /* 封面完全出现后,背后的 diff 瞬间消失 */
return flight;
}
/* 下轮 diff 内容就位no-fade 禁用过渡——移除 is-shown 与恢复显示若同帧,
红绿底色会以 0.3s 淡出形式闪一下(有底色无文字);禁用后瞬时消失,无残影 */
function resetDiffForRound(r) {
setDiffTexts(r);
diffView.classList.add('no-fade');
dlines.forEach(function (d) { d.classList.remove('is-shown'); });
diffView.classList.remove('is-sheet');
buildDlineChars();
diffView.style.visibility = '';
diffView.style.opacity = ''; /* 恢复渐隐前的可见no-fade 下瞬间生效,无闪) */
void diffView.offsetWidth; /* 强制样式计算:无过渡状态定格后再恢复过渡 */
diffView.classList.remove('no-fade');
}
/* 纸上复现新一轮 4 行内容(长短不一):旧行淡出 → 换行宽 → 淡入 */
async function refillPaper(r, myGen) {
paper.classList.add('is-refilling');
await step(300, myGen);
lines.forEach(function (l, i) {
l.classList.remove('is-new');
l.dataset.w = ROUNDS[r].paperW[i];
});
applyWidths();
paper.classList.remove('is-refilling');
await step(400, myGen);
}
async function play() {
var myGen = ++gen;
try {
for (var r = 0; r < ROUNDS.length; r++) {
// 0) 首轮等待开场;后续轮先展示盒内堆叠片刻,再在纸上复现新内容
if (r > 0) {
await step(520, myGen);
await refillPaper(r, myGen);
} else {
await step(650, myGen);
}
// 1) 笔飞到纸上,先后改写第 2、3 行;擦旧行时红行 - 飘出,写新行时绿行 + 飘出diff 实时构建)
await rewriteLine(lines[1], ROUNDS[r].edits[0].newW, dlines[0], dlines[1], myGen);
await rewriteLine(lines[2], ROUNDS[r].edits[1].newW, dlines[2], dlines[3], myGen);
// 2) 笔飞回智能体右侧
penPos.classList.remove('is-live');
nibTo(PEN_HOME.x, PEN_HOME.y, stageRect());
await step(500, myGen);
// 3) 收件箱始终显示(页面加载即在,无淡入步骤)
// 4) 文件卡片渐变出现diff 内容直接消失)→ genie 裁切收进盒子(先收最里,再依次向前)
var flight = await showFileCover(r, myGen);
await genieIntoBox(myGen, r, flight);
// 5) 为下一轮重建 diff 区(末轮保留收盒终态)
if (r < ROUNDS.length - 1) resetDiffForRound(r + 1);
}
} catch (e) {
if (e && e.message !== 'cancelled') throw e;
}
}
stage.addEventListener('click', function () {
reset();
play();
});
applyWidths();
penPos.classList.add('is-live'); /* 初始就位瞬移 */
nibTo(PEN_HOME.x, PEN_HOME.y, stageRect());
void penPos.offsetWidth; /* 强制样式计算后再恢复过渡 */
penPos.classList.remove('is-live');
play();
})();