宇之表无极,宙之端无穷。——张衡《灵宪》
这一页只有一个主角,就是中间那颗黑洞。它自己转,自己亮,指针挨近时转得更快一些。它一直待在那儿不动,但你能看见它周围的时空是弯的——背后那片天被拽成一个环,贴着洞口边缘绕了一圈。除此之外,画面里什么都没有 —— 直到你点一下任意处。
点下去的一瞬间,什么都没有发生,然后一颗光点亮起来。从这颗光点开始,一圈由内向外扩开的法阵凭空成形:内圈先亮,外圈依次跟上,射线连成结构,符文节点一颗颗变成悬在环上的小晶体。整座阵立起来 —— 中间升起一道细光柱,圆环抬起像一座圆台,前后层次分明。它在顶端停两三秒,等的就是你看清它。
然后是解体。从最外圈开始,能量线一段段断掉,断口炸成光粒;一层暗雾散开,整座阵慢慢躺平、变扁。这些碎片没有消失,它们被拉向黑洞:轨迹不是直线,是弯的,越近越快,绕着圈螺旋着收进去。接触到视界的那一瞬,碎片被拉成一根光丝、变亮、然后没了。
就在这一瞬,黑洞回应了:吸积盘亮一档,视界外那圈暖金亮环再亮几分,一圈极淡的引力波向外推出去,一两秒内回落。画面回到开始的样子,等下一次点击。
—— 而如果你点的是黑洞本体而不是空白,故事会走向另一条线:它先往内缩一下、视界暗下去,随即猛地弹回来,甩出一圈强力引力波扫过全场。被扫到的每一座法阵同时亮起、同时裂开 —— 你等于亲手把场上所有的仪式一次献祭掉了。它吞下去的每一座都记着,两座、四座、八座,它会一次比一次更大、更亮,直到第八座那次反向喷发,把自己烧回最初的样子。
二、一次点击里的六个阶段
法阵不是”画好一个东西然后播动画”,它是一条真的状态机,六个阶段各有自己的时长,时长带随机:
| 阶段 | 做什么 | 时长 |
|---|---|---|
成形 generating | 由内向外逐圈点亮,微弱轨迹先铺、能量再填满 | 1.2 ~ 2.0s |
升起 rising | 中心光柱立起、圆环抬升、符文浮成晶体 | 1.0 ~ 1.8s |
巅峰 active | 停住,自转继续 | 2 ~ 3s |
解体 dissolving | 由外向内逐段断裂,断口甩出碎片与暗雾 | 0.9s |
吞噬 absorbing | 残存结构躺平变扁,碎片被引力卷走 | (1.6 ~ 2.5s) ÷ 速度档 |
| 退场 | 连碎片都吞干净了,这座阵才真正从场上移除 | — |
最后那条是有意加的门槛:“场上没有这座阵”与”这座阵被吞干净”不是一回事。碎片还在飞的时候就把法阵抹掉,看起来就是”原地凭空消散”——而不是被黑洞吃掉。所以移除条件是「阶段走完」且「自己那片区域里没有粒子了」。
一个平面里同时最多留三座,第四座来的时候最旧的那座先转和解体。同一位置连点也不会叠出十座 —— 生成有 0.26 秒冷却。
三、伪三维:整页只有一条投影公式
这一页没有引 Three.js。法阵活在一个水平面里,像地上一座悬浮的仪式平台;一个三维点 (x, y, z)(z 向上)落到屏幕上,只在 project() 这一处换算:
// 整页只在这一个函数里做立体感,别处不许各写一套function project(cx, cy, x, y, z, k) { return [cx + x, cy + y * k - z];}k 是俯视的纵向压缩。z 直接减在屏幕 y 上 —— 于是”抬起”就是把 z 从小加到大,不需要第二套坐标。
关键在 k 不是一个常数,而是两张不同的值:
| 谁 | k | 为什么 |
|---|---|---|
| 黑洞(吸积盘、引力透镜) | 0.42 | 它是躺在页面平面里的天体,约 25° 俯视 |
| 法阵 | RITE_K = 0.84 | 它是浮在空中、正面朝向镜头的圆盘,几乎不压扁 |
🔴 这一条是”法阵不还原”最贵的坑。设定图里 16 张法阵全是正圆顶视曼陀罗(五芒、六芒、八芒、同心环、放射线);按 0.42 压成扁椭圆之后它们全挤成一团,形状一眼就不对。两者共用同一条
project(),只是传进去的k不同 —— 投影公式只有一个,压缩率各给各的。
高度的两个单位,混一次就全平
project() 收的 z 是像素,而法阵每条折线(Wire)上存的 z 是占该座升起高度 riseH 的比例(0 = 贴在盘底、1 = 盘面)。画之前乘回 riseH:
const zBase = r.riseH * lift; // 升起进度也在这里进去const [x, y] = project(r.cx, r.cy, rx, ry, zBase * w.z, RITE_K);🔴 就是这一行让整页法阵全是平的。 原来
z的取值是0.16 ~ 1.0,而绘制处只写了w.z * lift—— 高度实际是 0.1 ~ 1.0 像素。一座半径两百多像素的法阵,起伏不到一像素,投影出来当然是一张贴纸。症状不是”立体感弱”,是”看起来完全没有高度”。改成”比例 ×riseH”之后,同一批图元的高度是 十几到一百多像素。
盘面必须共面,立体感交给”盘厚”
盘面上的一切(同心环、星形、弦网、放射骨)都落在几乎同一个高度上(0.94 ~ 1.0,单位是 riseH 的比例)。这不是偷懒 —— 恰恰相反:原先把高度按”离中心多远”分成四层大台阶(外圈贴地、内圈抬高到 0.84),看着很立体,但同心环、星形、弦网被抬得互相错位,整座法阵读不成一个图形了。设定图里那些法阵全是同一平面上的完整曼陀罗,所以盘面必须基本共面。
立体感的预算于是全部挪到另一处:盘厚。外圈往下拉出一圈侧壁(skirt)—— 一圈下沿 + 96 条竖直母线,两条 wire、两次 stroke(),给出”这是个实心盘”的全部依据:
const DECK = 1; // 盘面高度(占 riseH)const BODY = 0.78; // 盘底高度 —— DECK − BODY 就是"盘有多厚"🔴 盘厚要薄。 这里先给过
0.58(厚度 = 0.42·riseH,最大能到0.18R),实测读出来是一只鼓 —— 侧壁比盘面上任何结构都抢眼,整座阵变成”扣在碗里的图案”。设定图里那些法阵是薄盘:厚度只是”这不是一张贴纸”的最低限度的证据,不该自己成为主角。
折线为此支持逐点高度 zs(给了就覆盖整条的 z),于是两类图元是同一件事:
- 两端半径不同、高度不同 → 一道坡;
- 两端同一位置、只有高度不同 → 一片竖刃(投影里就是一条竖线,一眼看出这东西有厚度)。
⚠️ 竖母线必须是同一 (x, y) 上的两个点。写成”斜着爬上去”的话整条会读成一根撑杆,“厚度”就没了。
同理,涡线(spiral)要”往里旋下去”也靠 zs,但给的是锥面(0.86 → 1.0,中心高)而不是台阶 —— 四层台阶那版是配旧台面写的,台面改成共面之后它塌成 0.94~1.0 的微起伏,等于什么都没做,涡线整条躺在同一层,投影里就退化成一圈套一圈的同心圆(“看着杂”的头号来源)。
立体感有了一半,剩下一半是前后分层。同一平面里的图元要分出”在后面”和”在前面”,靠的不是 z-index,而是算深度:所有图元按旋转后的 y 符号分两趟画,后半先画、前半后画,每段的亮度再按它落在哪一趟调一档(近半 0.95、远半 0.45)。这就是圆环看起来是”绕着黑洞立体的环”而不是”一条椭圆线”的原因。
⚠️ 这里踩过一次:在平面里分层,不能用 z-index “谁画在上面谁先判”。z-index 是给 DOM 分层用的,画布里的前后关系只能是”后画的压先画的”,所以顺序得自己算出来。
🔴 而且分两趟这件事本身翻过一次大车,代价是”法阵只剩一半”。当时用一个
drawing标志同时管两件事:既表示”当前子折线还连不连着”(决定lineTo还是moveTo),又表示”这条 path 里到底有没有东西”(决定要不要stroke())。一圈 97 点的圆环在第 95 段跨出本 half 被跳过时把drawing置回false,循环正好结束 —— 于是if (!drawing) return直接把整条wire丢掉,49 段明明都moveTo/lineTo过了却一次都没描。闭合曲线(圆、环、星)的最后一段永远落在接缝那一侧,所以几乎每条线都中招,整座法阵的上半圈是空的。症状看着像”亮度不均”,其实是”半个阵根本不存在”。修法是拆成两个标志:drawing管连接,any管”有没有内容”。
边饰语法:法阵的”贵气”全在那一圈
把设定图放大到一格一格看,才看清那圈边不是”一条圆线”,是一条完整的、分层刻满东西的边饰带。从外往里一共五层,缺一层就掉档:
| 层 | 是什么 | 落成什么 |
|---|---|---|
| ① | 外沿主圈 | 一条 circle(RO) |
| ② | 内侧副圈夹出的带(宽约 10% R),带里 96 根密齿 + 36 颗节点 | 三条 extra 合成 |
| ③ | 带外沿骑着一圈大纹样(三叶鸢尾/卷草/尖碑…,相邻两枚左右镜像) | 一族路径 stamp 成一条 extra |
| ④ | 带的内侧一圈铭文,字头朝外 | fillText + 一道暗描边 |
| ⑤ | 整座阵最外那圈锯齿能量边(那道”电光”轮廓) | 一条 360 点的闭合折线 |
这五层加起来的 wire 预算只有 7 条(齿/节点/纹样/锯齿各一条 extra,主副圈各一条),但密度的读感翻了几倍 —— 这正是”要走 extra 合成一条”那条规矩最值钱的地方。
🔴 纹样必须用本阵自己的色。一开始把那一圈纹样和锯齿边给了
CRIMSON(猩红,设定里是”坍缩/危险”的专用色),结果每一座法阵边缘都套着一圈红刺,和暗金/幽青/暗紫的主色直接打架 —— 那是”看着劣质”里最扎眼、也最容易修的一笔。
法阵是一整个发光的场,不是黑盘上的亮线
这是”劣质感”最后、也是最大的一块。只画折线的话,无论线多密,盘内永远剩下几块没有线经过的纯黑空底,整座阵读成”一个箍了边的空盘子”。
设定图里法阵是一整个发光的圆场:盘缘最亮、越往里越暗(光从边上打进来,中心是暗芯),而盘内没有一处是纯黑。所以绘制时在折线之后再加三到四层加色(lighter)的径向渐变:
盘缘亮箍 0.78R → 1.0R,alpha 0 → 0.3 ← 那道"亮箍"整场 0 → R,0.08 → 0.36 → 0.3 ← 把盘从黑里抬起来外溢光晕 1.0R → 1.22R,0.16 → 0 ← "这道光有厚度"(降级档省掉这层)盘心 0.6R 内一点冷白(带一个很慢的呼吸)🔴 剖面方向反了就毁了。 中心亮、边缘暗会读成”一颗发光的球”,盘就没了。设定图是边缘亮。
⚠️ 加色的峰值要控:这里是单层渐变铺一次,给到
0.3也不会顶到 255;但”大面积 ≤ 0.2”那条规矩管的是多层累加 —— 盘心那一层仍然只敢给0.22,因为它叠在整场上面。⚠️ 全部都要跟着盘面一起压扁:
translate到盘心之后scale(1, RITE_K),否则中心是一颗球,不是盘上的光。
配套还有两样”一眼读出这是立体阵”的东西:盘面上方倾斜着转的悬浮光环(0~3 圈),和盘内那层细放射线 + 斜织的弦(radial 72 条 + lattice 24 组,各自一条 extra,亮度只有 0.1)。
🔴 悬浮光环正圆转起来看不见 —— 转速全靠沿弧等距的”珠”给;环本身只负责形状。
🔴 细放射线同理:16~32 条又亮又等长的是辐条(车轮),72 条、亮度压到 0.1 的才是织物。条数和亮度的关系比形状更决定它读成哪种。
四、图元先折线化,再谈”逐段”
法阵有十三种阵型(三重圆环 / 六芒星 / 月相 / 裂纹封印 / 时空折叠 / 幽青八芒 / 暗金星轨,以及后补的太极八卦 / 莲花 / 符文卫星 / 玫瑰涡 / 尖芒星 / 星网),每种由圆、椭圆、多边形、星形折线、弦网、螺旋、花瓣、立鳍拼出来。如果每种都写一套”逐渐出现”的进度逻辑,就是十三套重复代码。
所以生成的那一刻,所有图元都被抽样成折线(Wire):一串扁平坐标、一个高度 z、一个”占外圈半径的比例” rank。之后画的时候只在这串点上走,于是:
- 由内向外点亮:每个图元有自己的展开窗口,
rank越小越早开始 —— 圆环、射线、符文一圈一圈往外亮,而不是整体一起淡入; - 由外向内解体:条件反过来写,
rank越大越早断 —— 最外圈先裂; - 前后分层:同一条折线,按点的 y 分两趟画。
一套数据,三种阶段各读各的字段。数据也是可复现随机(种子驱动的伪随机流):形状在生成那一刻抽一次就存下来,之后每帧只是重画同一份点 —— 所以”随机”不会让法阵在自转的时候自己抖。
⚠️ 种子得来自时钟。写死一个常数的话,每次打开页面的第一座法阵永远是同一个阵型,用户看到的”随机”其实是不随机。(可复现要的是”同一座阵前后帧不抖”,不是”每次打开都长一样”。)
五、引力那三项,缺一项都不对
碎片被吞的这段,是这一页最容易”看起来对、其实错”的地方。最后落在代码里是三行:
const a = ACC / Math.max(70, d); // ① 径向:越近越猛const curl = 0.7 * (0.5 + 0.95 * clamp(d / (v.h * 0.75), 0, 1));const nvx = sp.vx + dx * inv * a * dt - sp.vy * curl * dt; // ② 偏转:垂直于**速度**const nvy = sp.vy + dy * inv * a * dt + sp.vx * curl * dt;const keep = Math.max(0, 1 - 1.1 * dt); // ③ 阻尼:抽走能量sp.vx = nvx * keep;sp.vy = nvy * keep;① 负责”越近越快”:加速度与距离成反比,落到视界跟前时是初入时的十几倍。
② 负责”轨迹是弯的”。它必须垂直于速度,而不是垂直于半径。这两者长得像,结果完全相反:
- 垂直于半径的力(也就是”给一个永远同向的切向推力”),每一帧都在给粒子加角动量 —— 那是在做正功,能量只增不减。粒子会绕着越飘越远,最后从”被引力吸走”变成”加速逃逸”。实测:起点离视界 566px、初速 0,20 秒后飞到了 9754px 之外。
- 垂直于速度的力不做功,只改方向。弧线照样有,能量只从”往下落”里拿。
③ 负责”收得回来”。只有 ① ② 的话,粒子攒够切向速度就会变成永动的圆轨道:实测在 566px 处转了 1169° 还在原地打转 —— 物理上说得通(这是个有心力场里的稳定圆轨道),画面上就是”飘在那儿不进去”。
三项齐了,轨迹才有那种”被牵着走”的味道。这一页实测的数据:
| 出发点离视界 | 掉进去耗时 | 绕过的角度 |
|---|---|---|
| 300px | 0.6s | ~25° |
| 700px | 2.7s | ~320° |
| 1100px | 5.1s | ~375° |
一句话:远的地方绕着走(螺旋),近的地方几乎是直着扎进去(加速)。这正是 spec 想要的那种”被引力选中”的观感。
还有两个小的、但少了就会穿帮的细节:
- 粒子的寿命要按出发点离黑洞多远来给(
clamp(d / 150, 1.8, 7)秒)。写死 3.2 秒的话,远处来的碎片会在半路淡掉 —— 看上去是”消散”,不是”被吞”。- 单帧位移要钳制在视界半径以内。速度上限是 1500px/s,正常帧只走 25px,但卡一下(dt 被夹到 0.05s)就是 75px —— 足够一帧跨过视界。“吞进去”就变成了”凭空消失”。
六、黑洞那一层
黑洞画在最上面一张画布里,spec 要求它始终是视觉中心、不被法阵遮挡 —— 于是被引力卷进去的碎片,是消失在吸积盘后面的,物理上正好也对。它钉死在画面正中,不参与拖拽:引力透镜的中心是烘在星野位图里的,黑球一挪,被掰弯的那片天就跟它错开,看上去像画歪了。「会动的天体」和「被扭曲的空间」,静态构图里只能选一个。
这一页的路数是暖金:白炽 → 米金 → 琥珀,从前到后只有这一条色带。
吸积盘按远半 → 核心 → 近半三趟画:先把远侧半圈画在核心下面,画完近乎纯黑的核心与视界细环,再把近侧半圈压上去。盘体是一次径向渐变填充(内缘最亮、往外衰减),不是几条细光带 —— 后者是”荧光线”,不是”盘”。盘面近乎侧视(k = 0.045),所以它先是一片宽而暗的晕、中间再压一根细而亮的针,那根针一路伸出画外。外圈叠引力透镜的暖金爱因斯坦环,以及视界正上方 / 正下方两组被弯折的细弧 —— 那是盘的后半被引力抬到球上下的二次像,也是整页”引力味”最重的一笔。
指针挨近黑洞(hover),吸积盘转速提升、星尘环向内偏一点;法阵被吞掉时,pulse 叠加(同时吞两座有上限),吸积盘亮 40%、视界那圈亮环亮 90%、一圈引力波向外推,然后一两秒平滑回落。
空间扭曲:得让”有方向的东西”被推弯
点状星野做径向重映射只会变密,看不出空间被掰 —— 一片均匀的疏密变化读不出方向。想让它读成”时空被拽弯”,得先往星野里放有方向的长条:尘埃纤维、绕洞的漩涡丝、几道大扫帚臂。它们靠近洞口时被推着弯、被拉长,在极近距离被拉成切向拖影(采样时沿切向多取几个点取最大值),才真的像”空间本身”,而不是”画了一堆弧”。
⚠️ 漩涡丝要三样一起来才散得开:压扁(两个半轴分开给)、逐道旋转(越往外扭得越多,同方向)、半径抖动。只做前两样会变成”水波纹” —— 几百道丝自己对齐成一圈圈同心圆。这是量出来才发现的事。
⚠️ 而且这一涡不能烘。它最早和星野一起烘进位图里,数量、配色、剖面对得上一张参考图,但那一层一条都不会动,整页于是像一张壁纸。现在它是每帧画的一层活丝:半径存真实半径
b(单位 R),每帧用透镜方程正解把它映射成视半径,于是b < 1R的丝一定落在视界外 —— 物理上就是”洞口背后那片天被掰出来”,而且会转。差速来自rate = 2.6 / (1 + b·1.1),只跟一个只增不减的相位走,没有累积误差。
⚠️ 判断”哪一层真的在动”只有一个客观证据:把两个不同时刻的帧做像素差。动的层会显形,烘死的一条都不出现。
物理底子那一手是引力透镜:透镜方程 β = θ − θE²/θ 反解,对烘好的星野位图做一次径向重映射(θE = 1.0R、8R 窗口)。θ→θE 时 β→0 —— 紧贴视界外的那一圈采回来的是中心附近的天区,整片天被压进极窄一环,环上又密又亮,这就是爱因斯坦环。
⚠️ 三件必须记住的事:
- 只在烘的时候做一次。每帧重映射等于每帧读 500 万像素,两个数量级的代价。贴着视界的那圈环和细弧是每帧另画的,别搞混。
- 烘星野时不能把中心掏空。透镜要靠 θ≈1R 那圈像素去采
β < 1R的星 —— 留空则环上采回一片空白,“光堆在环上”当场落空。- 一半的丝要故意放在 1R 以内。它们在画面上本来是看不见的(被不透明的视界圆盖掉),是透镜把它们搬到环外——物理上正是引力透镜的二次像:“洞口背后那片天被掰出来”。
背景那层(星野 + 星云 + 底色 + 几根当”直尺”的尘埃纤维)连同上面那次透镜重映射,是一起烘进一张离屏位图的:这些东西本来就不动,动的只是视差。每帧重抽样上千颗星、几百道纤维,是白扔的预算。星数按画布面积给(w * h / 1500,1440×900 上约 860 颗),窄屏直接给 340 颗。
烘和活的分界线就是一句话:烘只放真的不动的东西,凡是要看出”在动”的一律每帧画。绕洞的丝、暖尘、盘上的亮点、上下细弧全是活层。诊断”没动感”的第一步就是量这个 —— 早先那 468 条丝全烘在静态位图里,一条都不会动,参数怎么调都是死的。
七、怎么玩
| 动作 | 结果 |
|---|---|
| 点画面任意处 | 在那里起一座法阵(点得离黑洞近,它会从黑洞中心向外展开) |
| 点黑洞本体 | 视界坍缩脉冲:先缩后弹、放出一圈强力引力波,场上所有法阵同时亮起并当场解体 |
| 指针靠近黑洞 | 吸积盘提速,星尘向内偏 |
| 按住成形后的法阵拖 | 松手后它自己慢慢飘向黑洞,并在漂移中解体 |
| 键盘 | 画面聚焦时 Enter / Space 起一座法阵;Esc 收起操作说明 |
| 触屏 | 全部等价:单指点击生成、单指拖动 |
| 左下「视界脉冲」 | 点黑洞那条路也留了一个按钮(那颗球看着是背景、不像按钮) |
| 右上「操作说明」 | 上面这些随时可查 |
| 右上「退回博客」 | 回到站里 |
「拖」这件事有个设计上的小陷阱,处理过了:
- 误触阈值:按下时只记”想抓什么”,位移超过 6px 才算真的在拖。否则”就想在法阵上点一下”也会占住状态,同一时刻的生成和拖拽全被挡掉。
黑洞本身不参与拖拽(理由见上一节)。所以这一页能被拖的只有一样东西:成形之后的法阵。
八、视界脉冲与觉醒四阶
点空白和点黑洞是两条完全不同的路,所以分流必须干净:按下时先判落在哪儿(命中次序 = 精度优先,不是”谁画在上面谁先判” —— 洞口是一个位置固定的圆,比随手一座法阵那圈稀疏的环窄得多),记一个意向;抬起来时确认”按下过 ∧ 没拖动”,才照意向走。意向必须在清空之前读走,否则永远分不清点的是哪个。
脉冲:一条包络管三件事
坍缩脉冲的全部形态都从一个秒数 collapse 派生:
- 半径包络:0→0.16s 收到 0.86R(视界真的在缩),0.16→0.34s 弹出去过冲到 1.20R,再 0.5s 落回 1.0R。
- 亮度包络:同一段时间里视界先暗到 0.4,回弹时过曝到 1.25,落回 1。只乘在透镜那一套上(环、细弧、活丝),盘要留着 —— 整页一起暗下去读成”熄灯”,不是”那个天体自己在收缩”。
- 符文:回弹那一刻,洞口边缘浮现一圈暗金古符文,随后散掉。
🔴 关键是半径包络写在 holeOf 里面,不是画的时候再乘一次。命中判定、法阵生成、四层绘制全都从 holeOf 取半径 —— 写在一处,于是”缩进去的那一瞬间点不到洞口""碎片穿进了球里”这类错位从设计上就不存在。这条有个能红的断言兜着(收缩期半径必须小于静息半径的 0.9 倍)。
脉冲还顺手把整页拧成一股:pulse 拉满 → 法阵层所有阵一起提亮 55%(“共振”),引力波变宽变亮,wave 从零重启。同一时刻只动一个共享量,四个读者自己响应。
觉醒四阶:阶数必须是派生的
吞到 1 / 2 / 4 / 8 座分别对应初醒 / 荧动 / 曜变 / 归墟。形态、法阵库、粒子密度、吞噬速度全读它。
export type Stage = 1 | 2 | 4 | 8;export function stageOf(swallowed: number): Stage { if (swallowed >= 8) return 8; if (swallowed >= 4) return 4; if (swallowed >= 2) return 2; return 1;}🔴 阶数是”已吞噬数”的函数,不是一个单独的字段。 因为八阶的过载喷发要把层级打回一阶 —— 派生写法只要把计数清零,形态、法阵库、反馈自己就全回来了;置位写法就得在”进阶”和”过载”两条路上各写一遍重置,迟早漏一条。
尺寸上,神给的要求是「黑洞初始时小一点,最大也就目前大小」——所以 8 阶 = 1.0(就是原来那个尺寸),1 阶收到 0.76,一路长回来:
| 阶 | 系数 | 1440×900 上的 R | 这一阶多了什么 |
|---|---|---|---|
| 初醒 | 0.76 | 71.8 | 基础四阵 |
| 荧动 | 0.84 | 79.4 | 盘亮 30%、一道细金属光环、4 颗绕行光点 |
| 曜变 | 0.90 | 85.0 | 八颗光点分两层反向绕行(冷色那一半逆行)、解锁高阶三阵、升幅 +20%、吞噬 +30% |
| 归墟 | 1.00 | 94.5 | 永久暗金符文环、再多一层暖金光点、星尘汇聚更快、吞噬脉冲更远更久、过载喷发 |
至于”吞了几座”这个读数:页面上没有数字。数量由洞口外围的光点个数暗示(初醒 0 颗、荧动 4 颗、曜变 8 颗、归墟再多一层),文字只给一个阶名。
这一节踩到的四个坑
① 压扁的环兜不住球。 视界是个正圆,而压扁的环在竖直方向的半轴只有
rr · flat。想让符文圈落在球外,必须rr · flat > 1。按盘那种 0.32 压扁,rr要给到 3R 以上才出得来 —— 那已经不是”贴着洞口边缘”了。所以符文圈只能压到 0.72~0.78(略扁、基本是个圆),rr · flat ≈ 1.1~1.2R,正好压在爱因斯坦环外沿:符文读成”刻在那圈光上的”。绕盘的光点同理,压扁比从 0.3 提到 0.46 —— 0.3 的话上下两颗掉进黑球里,而它们正是”数了几颗”的读数。
② 盘面里的东西必须画在核心之前。 那圈金属光环压扁 0.32,竖直半轴只有 0.53R —— 那两段本来就该被视界吃掉(土星环绕到行星背后)。画在核心之后,它就变成”贴着黑球描的一条线”。
③ 一圈符文只许
stroke()一次。 逐颗描边在加色混合下会把相接处叠成一串珠子(细弧上踩过一模一样的坑)。所有符文塞进同一个 path,代价是线宽只能整圈一个值 —— 这层不需要粗细变化。同理,22 道过载光纹也是一笔。它们的起点走正圆(1.08R,一定在球外)、远端才压扁 —— 反过来写的话竖直那几道的起点落进黑球里,等于在纯黑球面上划金线。
④ 换阶要重烘星野。 透镜参数 θE 跟着 R 走,而星野的透镜重映射只在烘那一步做一次。换阶时不重烘,活丝用的新 θE 和星野里的旧 θE 就成了两套几何。只在换阶那一帧做 —— 那是百万级像素的循环,绝不能每帧。
九、性能
四张画布分层:背景(低频,静止时 24fps 重绘)/ 法阵 / 粒子 / 黑洞。分层不是为了好看,是为了该省的那层真的能省:背景只贴一张位图,法阵只在有法阵时画,粒子层只在有粒子时画。
粒子是全局池,有上限:桌面 800 颗,窄屏 280 颗。池满了顶掉最老的一颗 —— 池子不增长、不阻塞。被吞到视界的碎片不是删掉,而是当场改造成一粒 0.22 秒的冷白闪光接着用;解体时的大颗粒顺手当”暗雾”用(画成径向渐变的雾团)。所以”能一直玩”和”不会越玩越卡”是同一件事。
引力计算是 O(粒子数):每颗粒子每帧一次 hypot + 几次乘加,没有两两相互作用。开销随粒子上限走,不随点击次数走。
静止降帧:没有法阵、没有手势、指针也不在附近时,物理照常按 rAF 走(时间不停),只是把”画”降到 24fps —— 吸积盘转得那么慢,看不出来。有法阵(或指针靠近)立刻回到满帧。
移动端降级:(pointer: coarse) / 屏宽 ≤ 760px / 逻辑核 ≤ 4 三者任一命中就走 lite 路线 —— dpr 上限从 2 降到 1.25、升起高度压到 0.72、粒子上限 280、星野压到 340 颗、并且跳过引力透镜那次重映射(它最贵)。触屏的 touch-action: none 只加在画布上,页面别处照常滚。
「减少动态效果」(prefers-reduced-motion):暗雾与星尘降到 25% 速度、视差关掉,但吸积盘照转 —— 它是这一页的存在感,停了就不像黑洞了。
十、这一页改掉的几个坑
留个记录,都是”类型检查一声不响、只有肉眼才看得出来”的那类:
- 秒和毫秒长得一样。共享态里的
now是Date.now()(毫秒),而动画常量写的是人读得懂的秒。直接相减等于把 2.4 秒当 2.4 毫秒用,症状不是”动画慢”,是”看起来根本没做动画”。模块内一律now * 0.001。 - 读数只被读、没人写。阶段读数(“成形 · 三座圆环阵”)的字段在一处重构里被删掉了写入方,类型检查完全看不出来 —— 页面上就是那块字永远停在”虚空静默”。修法是每帧从”最新那座法阵”派生,而且比较时要比拼好的整串,否则同一阶段换了一座阵,名字不会更新。
- 同一个假上下文喂两层。离线核对时把法阵层和粒子层传了同一个记录器,“光丝长度”里混进了法阵折线的长度(56px,远超 22px 的上限),判据假红。分开两个记录器才对。
- 断言要能红。这一页的引力公式改对之后,我回头故意把代码改回旧写法,确认相关判据真的翻红 —— 第一次它没红,因为旧写法要 20 秒才逃逸,而碎片只活 1.8~7 秒。于是把判据换成”有没有粒子真的抵达视界”(旧写法下这个数一直停在 502px,新写法 508px → 72px),这条才真正敏感。
- 加色混合下,“逐段圆头描边”必成虚线珠子。环和弧都是一段一段
stroke()出来的,每段给了圆头端点;在lighter下相邻两段的端点互相重叠、alpha 累加 —— 整条弧隔着几步看就是一个”虚线”,放大才发现是一串珠子(数值上是 255 / 225 的周期起伏)。修法是三选一:写成一笔(径向 / 线性渐变填充)、整条描边一次、或者非逐段不可时用lineCap: "butt"并把衰减交给线宽(越淡越细、细到 0 就跳过),别交给 alpha。一句话:“淡”由面积给,别由 alpha 给。
十一、还能往哪长
- 法阵库:十三种都是手搓的抽样器。把
buildSigil换成读数据(SVG path / 图元 DSL),就能外挂任意数量的阵型,甚至让访客投稿。 - 音效:这一页的节奏感很强,很适合配一套合成音(生成时一声低鸣、解体时碎响、吞噬时一记闷响 + 低频扫频)。用 WebAudio 现场合成,不引音频文件。
- 键盘切换阵型:现在是随机。加一排小按钮或数字键选定阵型,可以让”想画哪一个”变成一件能重复的事。
- 引力强度可调:把
ACC、偏转、阻尼三个数做成一个滑杆,就是一件很好玩的物理演示 —— 往一边拉是”直直地砸进去”,往另一边拉是”绕着转半天”。 - 多人/多屏:法阵的种子是纯数字,理论上可以分享一个种子,让别人看到一模一样的阵型。