很多人第一次搜“Python 代码雨”,心里想的其实很简单:满屏绿色字符往下掉,像老电影里那种数字雨。真动手写才发现,三十行代码确实能出画面,但离“炫酷”还差着一截:字是往下掉了,可整屏像被撒了一把豆子,稀稀拉拉没有纵深感;跑上十分钟,帧率从 60 掉到 20;换个字体,片假名全变成了方块。我前后改过四五版,把能踩的坑基本踩了一遍,下面这份东西就是我的完整思路和最终能用的代码。
这篇内容适合三类人:完全没接触过图形库、想拿一个完整小项目练手的;写过一版但效果不满意、想知道差在哪里的;以及想把代码雨嵌到别的地方当背景用的。我会把选型、数据结构、绘制顺序、参数调节和性能排查一条线讲透,代码是完整可运行的单个文件,复制走就能跑。
1. 一屏雨幕拆开看,其实只有“列、速度、余晖”三件事
1.1 代码雨看起来像雨,靠的是哪几个视觉特征
先把画面拆解成可描述的特征,写代码时才知道该控制什么。第一个特征是纵向分列,所有字符严格对齐在竖条里,横向上互不干扰,这正是它区别于普通“字符飘落”的地方。第二个特征是拖尾,每一个下落的字符后面都跟着一串逐渐变暗的残影,头部亮、尾巴淡,构成了纵深。第三个特征是不均匀的节奏,有的列掉得快,有的列慢吞吞,有的刚掉完立刻重来,有的会停一会儿,这种参差感是“像真的”和“像程序”的分水岭。
所以整个实现的核心就是三件事:把屏幕切成若干竖条、给每条一个下落速度、让画过的字符慢慢淡掉。剩下的都是在这三点上做加减法。我见过不少实现把精力全放在“随机字符”上,结果画面规整得像个表格,问题就出在速度分布太平均,所有列几乎是同步下落,一眼就看出来是循环。
反过来说,只要速度拉开差距、拖尾长度合适,哪怕字符集只用十进制数字,画面质感也能立住。这一点在我后来的修改里反复被验证:调参数带来的观感提升,远比换字符集明显。
1.2 终端、窗口库、游戏引擎,三条路各自的天花板
动手前先选技术路线,这决定了后面能走多远。我按自己的使用经验列一下:
| 路线 | 代表方案 | 上手成本 | 画面上限 | 适合场景 |
|---|---|---|---|---|
| 终端字符界面 | curses | 低 | 受限于字符网格与色数 | 服务器上跑、快速验证 |
| 窗口图形库 | 各类二维绘图库 | 中 | 可做字体、色彩、特效 | 桌面小工具、演示背景 |
| 游戏引擎封装 | 带硬件加速的框架 | 中偏高 | 可做后期处理、粒子 | 需要嵌进更大的程序 |
终端方案的优势是零依赖,任何有标准库的环境都能跑,缺点是刷新整屏容易闪,颜色只有有限几组,字号也没法调。窗口图形库的方案能做到逐像素控制、任意字体、任意颜色,代价是要处理窗口事件循环和资源管理。引擎方案适合你已经有一个游戏项目,顺手加个背景,单独为代码雨引入它反而累赘。
我最终选的是窗口图形库这条路,因为它能在“代码量可控”和“画面好看”之间取到平衡。下面所有代码都基于这个方向。如果你只想在服务器终端上看个效果,第 6 节有单独的终端版本。
2. 用列作基本单位,而不是用字符
2.1 屏幕切条:列宽不是随便定的
新手最容易犯的错是给每个字符建一个对象,然后让它们各自下落。这样写逻辑上能跑,但很快会遇到两个问题:一是碰撞感乱,字符之间会互相穿插,视觉上糊成一片;二是数量爆炸,960 宽的屏幕按 12 像素一个字算就有 80 列,如果每个字符一个对象,光是对象数量就上百,而且随时在增删。
正确的建模单位是列。屏幕宽度除以列宽,得到固定的列数,这个数字在整个运行期间不变。每一列维护自己的“头部”位置,头部往下走,走过的位置自然留下痕迹。这样对象数量是常数,逻辑也清晰:一列就是一根雨棒,长短、快慢、什么时候重来,全在这一列内部决定。
列宽怎么定?我的建议是直接用字体的实际字符宽度,而不是拍脑袋写死一个数字。等宽字体下,font.size("M")拿到的宽度就是每个字符占的横向空间,用它当列宽,字符之间既不重叠也不留缝。如果字体不是等宽的,取一个常用字符的宽度近似处理,画面会有一点点参差,但能接受。
col_w = font.size("M")[0] or FONT_SIZE columns = [Column(x, font_h, HEIGHT) for x in range(0, WIDTH, col_w)]顺手提一句,行高也别用字号硬顶。字体的行高(get_linesize())通常比字号大两三个像素,用行高当纵向步进,字符之间才会有呼吸感,不会挤成一坨。
2.2 Column 里每个字段的取值依据
一列雨需要记住的东西其实很少:横坐标、当前纵向位置、速度、当前字符、上一次绘制到的行号。我逐一说下取值逻辑。
纵向位置用浮点数存,不要用整数。因为速度是小数(比如每帧 2.7 像素),用整数存会让慢速列“卡住不动”——累加 0.7 三次才前进一个单位,画面看起来就是一跳一跳的。浮点存位置,绘制时才取整落到行上,运动就顺滑了。
速度的取值范围是整段代码里最影响手感的一个参数。我的经验区间是每帧 1 到 6 像素,低于 1 会显得停滞,高于 6 会在 30 帧下产生明显的跳行。而且要保证速度基本都小于一个行高,否则头部一次跨过两行,中间的字符就漏掉了,拖尾会出现断点。这一点我在第 4 节会详细展开。
当前字符单独存,不是每次都随机。如果每帧都随机取字符,头部会疯狂闪烁,看着像坏掉的灯管。正确做法是保持字符稳定,只在很低的概率下换掉,让画面有一点点“电子噪点”的活性。
上一次绘制到的行号是最容易被忽略的字段,但它是性能的关键,下面单独说。
2.3 行锁步:同一行不要重复绘制
最开始我的主循环是这样的:每帧遍历所有列,每列算出当前的纵向位置,画一个字符。跑起来画面是对的,但我盯着某一列看发现,慢速列的那一格特别亮,亮到发白,比周围的字符刺眼。原因很简单:速度慢的时候,同一列连续好几帧都落在同一行上,我每帧都往同一个像素位置叠一次颜色,叠加几次就饱和了。
解决办法是行锁步:只有当计算出的行号发生变化时,才真正去绘制。行号没变,说明这一帧这一列还停在同一行,什么都不用做。
row = int(self.y // self.font_h) if row == self.last_row: return None self.last_row = row return row这个改动带来的收益是双份的。视觉上,那格过曝的亮点消失了,亮度均匀了;性能上,慢速列从“每帧一次绘制”降到“每两帧或三帧一次”,屏幕上的绘制调用直接少了一截。我在这台老机器上测,光是这一处改动,平均帧耗时从 9 毫秒降到了 6 毫秒左右。
这背后其实是一个通用的图形编程原则:状态没变就不要重绘。听起来是废话,但真正写循环的时候很容易忘掉。
3. 完整代码:一个文件跑起来的版本
3.1 字体和字符集,最容易翻车的两行
代码雨对字体的要求比想象中高。首先必须等宽,否则列会歪。其次必须包含你要用的字符,否则会渲染成方块——就是那种空心豆腐块。我用过的那套半角片假名字符,在部分精简版的系统字体里就是缺失的,跑出来满屏方块,一开始还以为是编码问题,排查了半天才发现是字体没字形。
所以代码里做了两手准备。加载字体时给一串候选名,让字体系统自己挑第一个可用的;字符集则准备了两套,一套是片假名,一套是纯 ASCII 的十六进制字符加符号,后者几乎不可能缺字。切换只需要注释掉一行。
font = pygame.font.SysFont( "consolas,couriernew,dejavusansmono,liberationmono,monospace", FONT_SIZE )还有一点,字体的渲染结果要缓存。同一个字符、同一个颜色,渲染出来的图像是完全一样的,没必要每帧重新渲染。字符集固定的话,缓存总数就是“字符数”那么多个表面,几十上百个,内存完全无压力。但如果哪天你把字符集改成完全随机的字符,缓存就会无限膨胀,这时候得加上限或者干脆不缓存,这是个后面会踩到的坑。
3.2 主循环的绘制顺序与渐隐层
拖尾效果是这份代码的灵魂,它的实现方式决定了一切。有两种做法:一种是每帧清空屏幕,然后自己维护每列的历史字符列表,把整条尾巴重新画一遍;另一种是不清屏,每帧在屏幕上盖一层半透明黑色,让旧的像素自然衰减。
我选的是第二种。原因有两个:一是代码短,二是衰减是连续的,几百级灰阶过渡,比手动分几个亮度档位柔和得多。整条尾巴的长度由衰减速度和下落速度共同决定,非常自然。
盖黑层的做法有个细节要注意:不能直接对着显示表面填半透明色,因为很多显示表面本身不带透明度通道,填进去的透明度会被忽略。正确做法是提前建一个和屏幕等大的普通表面,填满黑色,用set_alpha设置整体透明度,然后每帧把它整块贴上去。
fade = pygame.Surface((WIDTH, HEIGHT)) fade.fill((0, 0, 0)) fade.set_alpha(FADE_ALPHA)绘制顺序不能乱:先盖黑层,再画字符。反过来就变成“刚画的字符立刻被压暗一次”,头部会发灰,失去那种锐利的亮绿。
3.3 完整代码与逐段说明
整个程序只有一个文件,除了图形库本身不需要别的依赖。
import random import sys import pygame WIDTH, HEIGHT = 960, 640 FONT_SIZE = 18 FPS = 30 FADE_ALPHA = 26 FADE_EVERY = 1 BG = (0, 0, 0) HEAD_COLOR = (215, 255, 215) # 半角片假名区间;若字体缺字,换成下面那行 ASCII 版本 CHARS = [chr(c) for c in range(0xFF66, 0xFF9E)] # CHARS = list("0123456789ABCDEF#$%&*+-<>/\\|[]{}") class GlyphCache: """把字符渲染成小表面并缓存,避免每帧重复渲染。""" def __init__(self, font): self.font = font self.cache = {} def get(self, ch): surf = self.cache.get(ch) if surf is None: surf = self.font.render(ch, True, HEAD_COLOR) self.cache[ch] = surf return surf class Column: """一列雨:横向位置固定,纵向位置随时间推进。""" __slots__ = ("x", "y", "speed", "char", "last_row", "font_h", "screen_h") def __init__(self, x, font_h, screen_h): self.x = x self.font_h = font_h self.screen_h = screen_h self.y = 0.0 self.speed = 0.0 self.char = CHARS[0] self.last_row = None self.reset(initial=True) def reset(self, initial=False): self.speed = random.uniform(1.0, 6.0) # 像素/帧 self.char = random.choice(CHARS) if initial: # 首次铺满全场,避免开场所有列同时从顶部出发 self.y = random.uniform(-self.screen_h, self.screen_h) else: self.y = random.uniform(-300.0, -self.font_h) self.last_row = int(self.y // self.font_h) def step(self): """推进一帧,返回需要绘制的行号;无需绘制返回 None。""" self.y += self.speed if self.y > self.screen_h + self.font_h * 24: self.reset() return None row = int(self.y // self.font_h) if row == self.last_row: return None # 行没变,跳过绘制 self.last_row = row if random.random() < 0.10: # 低概率换字,制造细碎跳动 self.char = random.choice(CHARS) return row def main(): pygame.init() screen = pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption("code rain") clock = pygame.time.Clock() font = pygame.font.SysFont( "consolas,couriernew,dejavusansmono,liberationmono,monospace", FONT_SIZE, ) font_h = font.get_linesize() col_w = font.size("M")[0] or FONT_SIZE cache = GlyphCache(font) fade = pygame.Surface((WIDTH, HEIGHT)) fade.fill(BG) fade.set_alpha(min(255, FADE_ALPHA * FADE_EVERY)) columns = [Column(x, font_h, HEIGHT) for x in range(0, WIDTH, col_w)] frame = 0 running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.KEYDOWN and event.key == pygame.K_ESCAPE: running = False frame += 1 if frame % FADE_EVERY == 0: screen.blit(fade, (0, 0)) # 先衰减 for col in columns: row = col.step() if row is not None and row >= 0: screen.blit(cache.get(col.char), (col.x, row * font_h)) pygame.display.flip() clock.tick(FPS) pygame.quit() sys.exit(0) if __name__ == "__main__": main()有几个地方值得单独解释。Column.reset里的initial参数是给第一次用的,让各列的初始位置散布在整个屏幕高度上,而不是全挤在顶部;否则程序一启动,你会看到一整排字符齐刷刷地往下掉,像瀑布开了闸,很不自然。之后的重置则把位置放到屏幕上方更远处,这样新的一列会从画面外进入,不会凭空出现。
__slots__是个小优化,把每个实例的字典去掉,属性访问改成固定偏移。列数上百的时候,内存占用和访问速度都有一点改善,代价是这个类不能再动态加属性。对这种结构固定的小对象,我觉得值。
step里判断重置的条件是“头部已经超出屏幕底部再往下二十多个行高”,这个余量是留给拖尾的——尾部还没走完就重置,画面会出现突兀的截断。
3.4 跑起来之后先调这两个参数
代码跑起来第一件事不是看效果,是确认字体对了。如果满屏方块,把字符集那两行换个位置;如果列是歪的,说明挑到的字体不是等宽,把候选列表里加上你系统里确定等宽的字体名。
确认画面正常之后,先调FADE_ALPHA,它决定拖尾长短。想看清楚它的作用,可以把它改成 10,你会看到尾巴拖得很长,整个屏幕糊成一片;改成 80,尾巴几乎消失,只剩一串孤立的点在掉。26 是我反复试下来比较平衡的值,但不同分辨率、不同字号下最舒服的值会浮动,建议自己扫一遍 15 到 45 之间的几个值。
再调速度区间。把random.uniform(1.0, 6.0)里的 6.0 改小到 3.0,所有列的速度集中在一个窄区间,画面马上变得很“规矩”,也立刻变丑了。这就是我在 1.1 里说的参差感。
4. 从“能跑”到“好看”,中间隔着这几个参数
4.1 拖尾长度的计算与手感对应关系
拖尾到底有多长,是可以算出来的。每帧盖一层透明度为 a 的黑色,一个已经画上去的像素经过 n 帧后剩余的亮度比例是 (1 - a/255) 的 n 次方。衰减到 10% 以下基本就看不清了,解这个方程得到帧数,再乘以每帧下落速度,就是拖尾的像素长度。
以FADE_ALPHA = 26为例,1 - 26/255 ≈ 0.898,要让亮度降到 10%,需要 ln(0.1)/ln(0.898) ≈ 21 帧。在 30 帧每秒下,一个字符从最亮到几乎消失大约 0.7 秒。
这个数字决定了拖尾的视觉长度:速度快的列,0.7 秒内掉落距离远,尾巴拉得长;速度慢的列,尾巴短。也就是说,拖尾长度会自动跟着速度变化,这是整屏渐隐方案比手动指定长度更自然的地方。手动指定长度的话,所有列的尾巴一样长,画面会显得很规整,缺少变化。
反过来说,如果你的观感需求是“每列的尾巴长度一致”,那就得走手动维护历史字符的那条路。两种方案没有优劣,看你要什么。
这里有个隐性约束要提醒:拖尾长度不能太长,长到覆盖整个屏幕高度时,屏幕中部的字符密度会饱和,最终整个屏幕变成一片均匀的绿色,看不到任何运动。出现这种情况,说明衰减太慢了,把透明度调大,或者把帧率调高。
4.2 速度分布要拉开,但不能乱
速度区间我定在 1 到 6,为什么是这个范围?上限 6 是被“不能跳行”约束住的。行高约 20 像素,一帧走 6 像素的话,三帧多一点才跨一行,字符是连续的下落感;如果上限放到 25,一帧跨一行还多,中间的行直接跳过,尾巴上会出现一节一节的空白,看起来像虚线。
下限 1 是被“不能停滞”约束的。小于 1 的速度在视觉上接近静止,尤其配合行锁步之后,好几帧才画一次,你会觉得那一列卡住了。1 像素每帧在 30 帧下是每秒 30 像素,虽然慢,但能看出在动。
还有一个容易忽略的点:速度不能是均匀分布。random.uniform是均匀的,落在 1 到 6 之间每个值的概率相等,大部分列速度都在 3.5 附近。视觉上,你需要的是“大部分列中等速度,少部分列特别快或特别慢”。这时候可以把均匀分布的样本做一次变换,比如取三次随机数的中位数,或者用平方让分布偏向一端。
我目前的处理很简单:保持均匀分布,但把区间拉得足够宽(1 到 6 已经跨了六倍),实际效果已经够参差了。如果你追求更极致的层次,可以再分两档:80% 的列在 1 到 3.5 之间,20% 的列在 4 到 6 之间,快慢对比会更强烈。
4.3 观感问题对照表
调参数的过程中会遇到一堆“看着不对但说不上哪不对”的情况,我整理了一份对照表,基本覆盖了我遇到过的全部:
| 现象 | 最可能的原因 | 调整方向 |
|---|---|---|
| 整屏像撒豆子,没有纵深感 | 拖尾太短,衰减太快 | 调小 FADE_ALPHA 到 15 至 20 |
| 屏幕糊成一片绿,看不出运动 | 拖尾太长,密度饱和 | 调大 FADE_ALPHA 到 40 以上 |
| 某些格子特别亮,像灯泡 | 慢速列在同一行重复绘制 | 确认行锁步生效,速度下限提高到 1.5 |
| 尾巴上有虚线断点 | 速度超过一个行高 | 速度上限压到行高以下 |
| 所有列动作整齐划一 | 初始位置没打散 | 检查 reset 的 initial 分支 |
| 字符疯狂闪烁 | 每帧都换字符 | 把换字概率降到 0.1 以下 |
| 满屏方块 | 字体缺字形 | 换成 ASCII 字符集 |
| 列之间有缝隙或重叠 | 列宽和实际字符宽度不匹配 | 用 font.size 计算列宽 |
这张表里,“所有列动作整齐划一”是最隐蔽的一个。如果你的初始化写成了self.y = -random.randint(0, 100),看起来是随机的,但范围太窄,所有列的起点差异只有 100 像素,在 640 高的屏幕上相当于几乎同时出发。正确做法是让初始位置覆盖整个屏幕高度,甚至更大范围。
5. 帧率掉下来时,我在查这几处
5.1 三个真实瓶颈的定位方法
代码雨跑到第二十分钟开始掉帧,这个现象我遇到过不止一次。定位的办法不复杂:在循环里打点计时,把耗时拆成“事件处理、衰减层、更新逻辑、字符绘制、画面翻转”五段,各打一个累计计时器,跑一百帧输出一次。哪一段占大头,一目了然。
我实测下来,绝大多数情况瓶颈集中在这三处。第一是字形渲染,如果没做缓存,每帧几百次字体渲染调用,这是最贵的。第二是整屏 alpha 混合,那块和屏幕等大的黑色表面每帧贴一次,走的是逐像素混合,61 万像素的开销在集成显卡上相当可观。第三是绘制调用次数,列数乘以平均每帧需要绘制的列比例,如果列宽设置得过小,列数翻倍,调用次数也翻倍。
有个反直觉的现象:字符绘制本身其实不贵,贴一个十几像素见方的小表面,成本很低。真正贵的是那次整屏混合。我在一台老机器上测,把衰减层去掉直接清屏,帧耗时从 8 毫秒掉到 3 毫秒,剩下的主要是窗口翻转的固定开销。
5.2 实测能拿回多少帧的几处改动
针对上面三个瓶颈,我的处理按收益排序如下。
第一,字形缓存。这个改动是必须的,不做的话在小字符集下收益巨大。同样是 80 列、30 帧,不做缓存时帧耗时约 14 毫秒,做完缓存降到 8 毫秒左右。缓存本身代码就十行,性价比最高。
第二,降低衰减层频率。这是我在老机器上用的招。既然整屏混合贵,那就别每帧都混合,改成每两帧混合一次,同时把透明度翻倍。物理上等价,开销减半。
FADE_EVERY = 2 fade.set_alpha(min(255, FADE_ALPHA * FADE_EVERY)) # 主循环里: if frame % FADE_EVERY == 0: screen.blit(fade, (0, 0))代价是拖尾的灰度过渡会粗一档,从几百级降到一百多级。在 30 帧下肉眼几乎看不出区别,我觉得完全值。如果改成每三帧一次,土褐色阶就开始明显了,能看出条带。
第三,行锁步。前面讲过,除了修掉亮点,还顺带省了绘制调用。慢速列占比越高,收益越大。
第四,适当加大列宽。列宽从 12 加到 16,列数直接从 80 降到 60,所有跟列数相关的开销同比降 25%。代价是画面变“稀疏”,这个要看你的审美取向。我个人更偏好列密一点,所以宁愿牺牲帧率也不加宽,你可以根据自己的机器取舍。
5.3 慢机器上的自适应策略
如果你的程序要发给别人用,机器性能不可控,可以做一层简单的自适应。思路是记录最近若干帧的平均耗时,如果持续超过某个阈值,就自动降低配置:先把衰减层频率从每帧改成每两帧,还不够就把帧率上限从 30 降到 24,再不够就减少列数。
avg = sum(recent) / len(recent) if avg > 40 and fade_every == 1: # 平均每帧超过 40 毫秒 fade_every = 2 elif avg > 55 and fps_limit > 24: fps_limit = 24这种降级策略在游戏里很常见,搬到小工具上同样有效。关键是降级要平滑,别出现“突然变卡然后突然变流畅”的抖动,所以判断条件里加了持续多帧的门槛。
另外提一句垂直同步。如果运行环境支持,开启垂直同步能让画面翻转和显示器刷新对齐,撕裂感会消失,同时也天然限制了帧率,不需要额外做时钟控制。开不了也没关系,普通的帧率限制足够。
6. 没有图形库的场合:终端版代码雨
6.1 curses 的坐标体系与颜色对
有时候你只有一台终端,或者想把它做成远程连上去就能看的小玩意,这时候就得用终端方案。标准库里有个跨平台的终端控制模块,核心概念是“屏幕”对象,用addch(行, 列, 字符, 属性)往指定位置写字符。
它的坐标是先行后列,这点和图形库的 (x, y) 刚好颠倒,写的时候特别容易弄反。我第一版就是按照 (x, y) 的顺序写的,结果画出来是一条斜线。
颜色方面,终端用的是“颜色对”,也就是前景色加背景色的组合,需要先注册再用。
import curses import random import time CHARS = [chr(c) for c in range(0xFF66, 0xFF9E)] def run(stdscr): curses.curs_set(0) stdscr.nodelay(True) curses.start_color() curses.use_default_colors() curses.init_pair(1, curses.COLOR_GREEN, -1) curses.init_pair(2, curses.COLOR_WHITE, -1) green = curses.color_pair(1) head = curses.color_pair(2) | curses.A_BOLD h, w = stdscr.getmaxyx() speed = [random.uniform(0.05, 0.4) for _ in range(w)] pos = [random.randint(-h, h) for _ in range(w)] length = [random.randint(4, 12) for _ in range(w)] while True: key = stdscr.getch() if key in (ord("q"), ord("Q"), 27): break stdscr.erase() for x in range(w): pos[x] += speed[x] if pos[x] - length[x] > h: pos[x] = -random.randint(0, h) length[x] = random.randint(4, 12) for i in range(length[x]): y = int(pos[x]) - i if 0 <= y < h - 1: try: stdscr.addch(y, x, random.choice(CHARS), head if i == 0 else green) except curses.error: pass stdscr.refresh() time.sleep(0.05) curses.wrapper(run)终端版是手动维护拖尾长度的:每列记一个长度,从头部往上画这么多格,超出范围的直接不画。这样就不需要渐隐层了,因为终端做半透明衰减本来也不现实。
6.2 终端版的三个硬限制
第一个限制是右下角不能写。终端的最后一行最后一列写字符会触发滚动,很多实现直接抛异常。所以循环里要判断y < h - 1,并且包一层异常捕获兜底。这行 try 看着丑,但不加的话程序在特定窗口尺寸下会直接崩。
第二个限制是字符宽度。终端里中文字符占两列,半角字符占一列。如果字符集里混了全角字符,后面所有字符的位置都会错开,整列歪掉。上面用的半角片假名在 Unicode 里属于半角区,宽度是一列,但能不能正常显示取决于终端的字体和区域设置配置,换个环境可能就变成问号或者方块。这一点没有通用解法,只能实测。
第三个限制是刷新成本。每次erase加整屏重绘,八十列乘以十几格,一帧要写上千次字符。终端模拟器的吞吐量有限,帧率一高就开始闪。我的做法是把帧间隔提到 50 毫秒,也就是 20 帧,肉眼已经够用。如果还是闪,可以试着只重绘变化的部分,不整屏擦除,但那样代码复杂度会上一个台阶,我觉得不值。
终端版的观感注定比图形版粗糙,但它的优势是随处可跑,几个朋友连同一台机器看个乐子的时候很方便。
7. 改造成自己需要的样子
7.1 换字符集和配色带来的气质变化
字符集是成本最低、效果最明显的改造点。我试过的几套:纯二进制 0 和 1,气质偏“数据流”;十六进制加运算符号,偏“终端界面”;纯 ASCII 可打印字符全量,画面非常稀碎,适合做背景;片假名是最经典的,绿色调性也最贴。如果想混排中文字符,要注意纵向对齐会被破坏,等宽字体里中文占两格,列宽得按中文宽度算,否则列会歪。
配色方面,改一行就能换风格:冷白加冰蓝,像科幻界面;橙红偏暗,配黑底有种旧显示器的感觉;纯白单色,最干净的极简风。改的时候注意头部颜色要比拖尾亮,这是纵深感的基础。我试过把头部设成和拖尾一样的颜色,画面立刻变成一片均匀的色块,完全没有