一步步从终端渲染图片到制作游戏

动机

之前水过一篇文章,偏整活性质,被人评论太水,痛定思痛,深刻反思,打算好好介绍一下,虽然仍然是整活性质,但会把细节介绍的更清楚。

PS:文章内容有点多,只想看成果的话直接跳到文章末尾就可以

终端

本文的所有案例在以下终端跑过,不能保证在其他终端也能完美运行:

  1. iTerm2
  2. Alacritty
  3. Terminal.app(MAC 自带终端)
  4. VSCode Terminal

如何美化终端输出

电影里的终端:

现实的终端:

不能说完全一样,也可以说是毫无关系...

终端是只能输出纯文本的信息,这在一定程度上说明其可塑性是非常低的。但即使是纯文本,美化的手段还是存在的,我们日常是过程中见到的:

  1. 下载时能根据进度变换的进度条
  2. 根据状态改变文本颜色的 log 信息
  3. ...

如果有写过 CLI,那么对于 chalk 这个包一定非常熟悉,它对于你想在终端中输出彩色文本非常有用,使用它你可以使用不同的颜色和样式来区分输出。 如果你看过 chalk 的源码,会发现它的源码代码量非常少,但是如果你没有提前了解过相关的知识,阅读起来基本上是非常困难的,其本质上是在终端中使用 ANSI 转义序列添加颜色和样式。

ANSI 转义序列

ANSI转义序列(ANSI escape sequences)是一种带内信号的转义序列标准,用于控制终端上文本的光标位置、颜色和其他选项。大部分以 ESC 转义字符和"["字符开始,终端会把这些字节序列解释为相应的指令,而不是普通的字符编码。

ESC 转义符:\x1b 、 \\e 或 \u001B,他们本质上是一样的

举两个 🌰:

  1. 输出红色的 "hello world", echo "\u001b[31mhelloworld\u001b[0m"
  2. 输出红色+加粗+下划线的 "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:

  1. 前景色:\u001b[38;5;${code}m
  2. 背景色:\u001b[48;5;${code}m

它们在终端的表现形式基本上一样

颜色 - 24位

24 位对应的就是 RGB 的形式,当然终端是不支持带透明度的 32 位颜色(RGBA),我测试的几个终端来说,Alacritty、iTerm 2 和 VSCode 的终端都是支持的,MAC 自带的 Terminal.app 不支持

"苹果,出列!!!"

24 位对应的 code:

  1. 前景色:\u001b[38;2;<r>;<g>;<b>m
  2. 背景色:\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)

启动终端,运行!

? 看似合理,但我不能接受,其次是后面的 % 是什么东西?

理一下,终端的输出不会覆盖,所以正确的流程应该是(下划线代指光标):

  1. 10**_** (将光标向左移动两位
  2. 1 0 (光标在1的位置,此时输出 11
  3. 11**_** (重复上述操作即可

实战,实现终端常见的下载时的进度条

梳理流程

  1. ░░░░░░░░░░_ (光标向左移动 str.length
  2. ░░░░░░░░░ (输入新的进度
  3. ▓░░░░░░░░░_ (重复上述操作

代码实现:

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 的作用在上文提到的资料有详细介绍):

  1. \u001b[<n>D:代表的是左键,向左退 n 格
  2. \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 还是 半块字符"▀",流程本质上都是一样的:

  1. 读取图片的 ImageData 数据,可以使用 node-canvas,API 和 WEB 端的 Canvas 的 API 是相同的,createCanvas => getContext('2d') => loadImage() => drawImage() => getImageData() 即可得到图片的 ImageData 数据
  2. 通过第一步我们就能获取到图片的 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;
  }
});

解释:

  1. stdin.setRawMode( true ); 键盘输入的字符能及时获取,此外,所有终端对字符的特殊处理都不再生效,比如 control + c 不能终止进程,所以我做了 ESC 退出的功能
  2. process.stdin.resume(); 读取 stdin 输入,进程不会自行退出,本实例因为定时器的存在,是不需要的,但还是给加上了
  3. 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 终端

成果

源码:github.com/shiyangzhao...

通过 npx 执行命令:npx terminal-pixel@latest

两种模式,一个是像素另一个是游戏

对于像素,输入相对的图片/视频路径(视频需要安装 ffmpeg),再选择是使用像素点还是 ascii code 渲染,就可以看到相对应的输出

游戏模式的话,目前只实现了贪吃蛇,因为贪吃蛇的实现难度非常小,后续可能考虑支持其他游戏,比如俄罗斯方块等等

其实总体上还是花费了很多心思和时间,如果你能喜欢,能给仓库点一个小小的 star 就太感谢了

素材:

  • Pokemon
  • 🐔你太美.mp4
  • bad-apple.flv
  • ME!ME!ME!.mp4

引用/参考:

相关推荐
拉不动的猪1 分钟前
前端常见数组分析
前端·javascript·面试
小吕学编程18 分钟前
ES练习册
java·前端·elasticsearch
Asthenia041225 分钟前
Netty编解码器详解与实战
前端
袁煦丞30 分钟前
每天省2小时!这个网盘神器让我告别云存储混乱(附内网穿透神操作)
前端·程序员·远程工作
一个专注写代码的程序媛1 小时前
vue组件间通信
前端·javascript·vue.js
一笑code2 小时前
美团社招一面
前端·javascript·vue.js
懒懒是个程序员2 小时前
layui时间范围
前端·javascript·layui
NoneCoder2 小时前
HTML响应式网页设计与跨平台适配
前端·html
凯哥19702 小时前
在 Uni-app 做的后台中使用 Howler.js 实现强大的音频播放功能
前端
烛阴2 小时前
面试必考!一招教你区分JavaScript静态函数和普通函数,快收藏!
前端·javascript