动机
之前水过一篇文章,偏整活性质,被人评论太水,痛定思痛,深刻反思,打算好好介绍一下,虽然仍然是整活性质,但会把细节介绍的更清楚。
PS:文章内容有点多,只想看成果的话直接跳到文章末尾就可以
终端
本文的所有案例在以下终端跑过,不能保证在其他终端也能完美运行:
如何美化终端输出
电影里的终端:
现实的终端:
不能说完全一样,也可以说是毫无关系...
终端是只能输出纯文本的信息,这在一定程度上说明其可塑性是非常低的。但即使是纯文本,美化的手段还是存在的,我们日常是过程中见到的:
- 下载时能根据进度变换的进度条
- 根据状态改变文本颜色的 log 信息
- ...
如果有写过 CLI,那么对于 chalk 这个包一定非常熟悉,它对于你想在终端中输出彩色文本非常有用,使用它你可以使用不同的颜色和样式来区分输出。 如果你看过 chalk 的源码,会发现它的源码代码量非常少,但是如果你没有提前了解过相关的知识,阅读起来基本上是非常困难的,其本质上是在终端中使用 ANSI 转义序列添加颜色和样式。
ANSI 转义序列
ANSI转义序列(ANSI escape sequences)是一种带内信号的转义序列标准,用于控制终端上文本的光标位置、颜色和其他选项。大部分以 ESC 转义字符和"["字符开始,终端会把这些字节序列解释为相应的指令,而不是普通的字符编码。
ESC 转义符:\x1b 、 \\e 或 \u001B,他们本质上是一样的
举两个 🌰:
- 输出红色的 "hello world",
echo "\u001b[31mhelloworld\u001b[0m"
- 输出红色+加粗+下划线的 "hello world",
echo "\u001b[31;1;4mhelloworld\u001b[0m"
两个都是 ESC code 为 ^[<code>m
的例子,31 指红色的前景色,1 指的是文本加粗,4 指的是下划线,这几个数字都是 SGR (Select Graphic Rendition) 的参数,中间用分号隔开。尾部的 \u001b[0m
是用来重置效果
关于 SGR 参数: en.wikipedia.org/wiki/ANSI_e...
ANSI 转义序列相关资料
维基百科:en.wikipedia.org/wiki/ANSI_e...
ESC code 详细说明: espterm.github.io/docs/VT100%...
简而言之就是不同的 ESC code 直接和字符串拼接:"\u001b[<code>m\u001b[<code>D" + 一些字符串
基于 ANSI 的颜色和动画
简单介绍下 ANSI 转义序列在我们工作中的一些应用
基于 ANSI 的文本颜色
如上文的🌰,颜色是 ESC code 为 ^[<n>m
的一种,纵观整个终端颜色的发展,过程是:3位 和 4位 => 8位 => 24 位,随着位数的提高,颜色数量越来越多,对于 ANSI 转义序列的来说,分为前景色和背景色
颜色 - 3位和4位
前景色和背景色各8种,格式为:\u001b[<code>m
如图片所示,每个终端的表现都不太一样(文字和颜色无关):
颜色 - 8位
你玩过的 NES 和 Game Boy 的颜色都是 8 位,总共 256 种颜色
对应的 code:
- 前景色:
\u001b[38;5;${code}m
- 背景色:
\u001b[48;5;${code}m
它们在终端的表现形式基本上一样
颜色 - 24位
24 位对应的就是 RGB 的形式,当然终端是不支持带透明度的 32 位颜色(RGBA),我测试的几个终端来说,Alacritty、iTerm 2 和 VSCode 的终端都是支持的,MAC 自带的 Terminal.app 不支持
"苹果,出列!!!"
24 位对应的 code:
- 前景色:
\u001b[38;2;<r>;<g>;<b>m
- 背景色:
\u001b[48;2;<r>;<g>;<b>m
Alacritty 和 iTerm 2 基本上是一样的,MAC 自带的终端只支持 8 位,不能正确识别
基于 ANSI 转义序列的动画
如果我要实现 10 => 20 不断变化的动画,这不有手就行
js
const sleep = (t) => new Promise((r) => setTimeout(() => r(), t));
const total = 20;
(async function inter(i) {
if (i <= total) {
await sleep(200);
process.stdout.write(String(i));
inter(i + 1);
}
})(0)
启动终端,运行!
? 看似合理,但我不能接受,其次是后面的 %
是什么东西?
理一下,终端的输出不会覆盖,所以正确的流程应该是(下划线代指光标):
- 10**_** (将光标向左移动两位
- 1 0 (光标在
1
的位置,此时输出11
- 11**_** (重复上述操作即可
实战,实现终端常见的下载时的进度条
梳理流程
- ░░░░░░░░░░_ (光标向左移动 str.length
░
░░░░░░░░░ (输入新的进度- ▓░░░░░░░░░_ (重复上述操作
代码实现:
js
const sleep = (t) => new Promise((r) => setTimeout(() => r(), t));
const doneStr = "▓";
const undoStr = "░";
const total = 20;
(async function inter(i, prev = 0) {
if (i <= total) {
await sleep(200);
const done = doneStr.repeat(i);
const undo = undoStr.repeat(total - i);
const str = `waiting: ${done}${undo}${(i * 100) / (total)}%`;
process.stdout.write(`\u001b[${prev}D\u001b[K${str}`);
inter(++i, str.length);
return;
}
console.log();
})(0)
最后的 console.log() 是用来除去上文提到的
%
,本质上就是最后的输出要换行,在结尾接上\n
也是可以的
分析(code 的作用在上文提到的资料有详细介绍):
\u001b[<n>D
:代表的是左键,向左退 n 格\u001[K
:从光标右侧清除行,主要考虑 89.9% => 90%,如果不清除会变成 89.9% => 90%9(终端是等宽字体
效果展示
迫不及待,开始整活
终端渲染图片
在终端把图片渲染出来
利用 ascii
首先要做的就是实现 image-to-ascii
,查询了相关资料,实现非常简单,跟着我做,以后也可以在不懂计算机知识的同学(对象)面前装逼了。
image-to-ascii
的核心就是灰度算法,灰度,即亮度,比如白色的"亮度"最大,灰度区间是 0~255
:
(0.2126 * red + 0.7152 * green + 0.0722 * blue)
选取一组 ascii code 作为像素,他们按照屏幕上占用的空间排序,即"亮度",占用空间越大,亮度越低,利用 255 / pixel.length 即可得到权重,再根据权重找到颜色对应的下标,代码非常简单:
ts
export const getAscii = (data: Uint8ClampedArray, index: number) => {
const r = data[index];
const g = data[index + 1];
const b = data[index + 2];
const a = data[index + 3];
const i = (0.2126 * r + 0.7152 * g + 0.0722 * b);
return a !== 0 ? pixel[Math.floor(i / weight)] : pixel[0];
};
但实际的展出效果感人,举个例子,素材是下面的图片
效果:
有点抽象,但是我们前面可是学习了 ANSI 转义序列的知识啊,我们给其加上颜色
效果是不是好太多了。
真正的像素
上面的实现已经比较完美了,但难道就止步于此了吗,我偏不,搜索引擎,启动!!! 在我日夜奔波,不断尝试下(花了我宝贵的十分钟时间),找到了一个完美的字符,半块字符"▀",在终端表现是非常标准的一个像素:
迫不及待,我们来用它组成一个 2x2 的方块试试,echo "▀▀\n▀▀"
:
啊~因为它只是半块字符,没法填充满,完了,这下路不是堵死了...
投机取巧
虽然半块字符没法填充,但如果搭配上背景色,是不是就是相当于上下两个像素拼接在一起了?
尝试输出一个 2x2 的方块: echo "\u001B[40m▀\u001B[40m▀\u001B[0m"
泪流满面,果然还得是我,多完美的像素。
实现渲染
根据 ImageData
数据,拿到像素的 rgb
,进行渲染
ts
export const renderImageData = (imageData: ImageData) => (outStr = '') => {
const { width, height, data } = imageData;
for (let row = 0; row < height; row += 2) {
for (let col = 0; col < width; col += 1) {
const topIndex = ((row * width) + col) * 4;
const bottomIndex = (((row + 1) * width) + col) * 4;
const color = getColorBy(data, topIndex)('color');
const bgColor = getColorBy(data, bottomIndex)('bgColor');
outStr += bgColor ? `${bgColor}${color}▀${ansi16mClose.all}` : `${color}▀${ansi16mClose.all}`;
}
outStr += '\n';
}
return outStr;
}
需要额外考虑的点,行数可能是偶数,也可能是奇数,偶数的情况,最后2行是 前景色 + 背景色,如果是奇数,那么最后一次遍历只有1行,此时应该是只有前景色
画布
此时,我们已经实现了 Canvas 的能力,画布的属性:
width = process.stdout.columns
文本的一行包含前景色和背景色
height = process.stdout.rows * 2
有的时候我们需要排除光标的影响:height = (process.stdout.rows - 1) * 2
渲染图片原理
无论是转成 ascii code 还是 半块字符"▀",流程本质上都是一样的:
- 读取图片的 ImageData 数据,可以使用 node-canvas,API 和 WEB 端的 Canvas 的 API 是相同的,
createCanvas
=>getContext('2d')
=>loadImage()
=>drawImage()
=>getImageData()
即可得到图片的ImageData
数据 - 通过第一步我们就能获取到图片的
ImageData
,再根据上面是实现的renderImageData
方法,就能渲染相对应的图片了
有些细节还是需要优化,比如图片大小,图片的宽度应该和终端相匹配,当图片宽度小于终端的宽度时,Canvas 宽度 = 图片宽度,Canvas 高度 = 图片高度:
当宽度大于终端时,Canvas 宽度 = 终端宽度,Canvas 高度 = 图片高度 * (终端宽度 / 图片宽度),因为你要保证图片比例,当对应的像素也会降低:
当然,不只是像素,当你把字体缩小到一定程度后,即使是"高清"图片,也完全能渲染出来
终端渲染视频
前面我们已经实现了在终端渲染图片,无论是通过像素点还是 ascii code,渲染视频肯定也不在话下。如果你在 web 端做过 Canvas 中渲染视频,那么理解起来就更简单了,因为流程本质上是一样的,在 web 端,就是借助 Video 标签,获取视频帧然后在 Canvas 里渲染出来。
这里,我们依旧按照这个思路去处理,不同的是我们没有直接获取视频帧的方法,需要借助其他工具。
ffmpeg
ffmpeg 算是比较有名的处理/剪辑视频的工具,你需要先下载下来,保证其在终端是可用的,MAC 用户可以通过 brew install ffmpeg
下载,最终的效果就是,我在终端执行 ffmpeg 不会报错说找不到这个命令。 ffmpeg 具体执行的参数非常多,我这里不做介绍,我自己查询了文档,写了个我们本次需要的命令:
ffmpeg -i ${filePath} -f image2 -vf fps=fps=${config.fps} "${imagesPath}/video-%d.png"
解释下,就是接受一个视频路径,按照一定帧率提取视频帧图片,按照 video-1、video-2 ... 的格式输出到另一个文件夹下
处理得到这些图片后,只需要遍历他们获取到路径,再通过上面实现的渲染图片的方法,就能在终端播放视频了,赶紧试一试:
用 ascii code 试试那个男人
即使知识 ascii code,那个男人还是这么帅气(转 gif 的时候帧率变低了。。
开发游戏
之所以能够渲染图片和视频,本质上是我们实现了 Canvas 渲染的能力,提到 Canvas,那就不得不提游戏了,没错,大的要来了,我们的目标,复刻经典经典游戏之贪吃蛇!
键盘"事件"
既然是游戏,操控的能力一定是要有的,我们可以通过 process.stdin 的 data 事件读取键盘输入,然后控制蛇的前进方向,代码
ts
const stdin = process.stdin;
stdin.setRawMode(true);
stdin.resume();
stdin.setEncoding('utf8');
stdin.on('data', (key) => {
if (JSON.stringify(key) === JSON.stringify(keyboard.Esc)) {
this.exit();
return;
}
switch (JSON.stringify(key)) {
case JSON.stringify(keyboard.ArrowDown):
if (this.direction !== Direction.Up) this.nextDirection = Direction.Down;
break;
case JSON.stringify(keyboard.ArrowUp):
if (this.direction !== Direction.Down) this.nextDirection = Direction.Up;
break;
case JSON.stringify(keyboard.ArrowLeft):
if (this.direction !== Direction.Right) this.nextDirection = Direction.Left;
break;
case JSON.stringify(keyboard.ArrowRight):
if (this.direction !== Direction.Left) this.nextDirection = Direction.Right;
break;
default:
return;
}
});
解释:
stdin.setRawMode( true );
键盘输入的字符能及时获取,此外,所有终端对字符的特殊处理都不再生效,比如 control + c 不能终止进程,所以我做了 ESC 退出的功能process.stdin.resume();
读取 stdin 输入,进程不会自行退出,本实例因为定时器的存在,是不需要的,但还是给加上了stdin.setEncoding( 'utf8' );
默认输入字符是二进制
贪吃蛇实现
核心逻辑就是蛇的移动,看似是蛇的每个部位移动,但其实就是沿着移动的方向,添加新的头,去掉尾部,如果新的头的位置和食物是一样的,那么尾部不会去掉,随机在空白区域生成新的食物。
碰撞检测:
snake.body.every(item => head === item || !isSamePlace(head, item))
边缘检测:
0 <= head.x <= width - 1 && 0 <= head.y <= height - 1
"实机"演示
终端性能体验
终端 stdout 的输出性能很差很差,网上搜寻资料是说终端的读写不同于文件的读写,基本上没有使用缓冲技术,没有可优化空间,只能用性能更好的终端。
性能体验:Alacritty > iTerm 2 > VSCode 终端
成果
通过 npx 执行命令:npx terminal-pixel@latest
两种模式,一个是像素另一个是游戏
对于像素,输入相对的图片/视频路径(视频需要安装 ffmpeg),再选择是使用像素点还是 ascii code 渲染,就可以看到相对应的输出
游戏模式的话,目前只实现了贪吃蛇,因为贪吃蛇的实现难度非常小,后续可能考虑支持其他游戏,比如俄罗斯方块等等
其实总体上还是花费了很多心思和时间,如果你能喜欢,能给仓库点一个小小的 star 就太感谢了
素材:
- Pokemon
- 🐔你太美.mp4
- bad-apple.flv
- ME!ME!ME!.mp4
引用/参考: