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

动机

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

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

引用/参考:

相关推荐
m0_748247552 小时前
Web 应用项目开发全流程解析与实战经验分享
开发语言·前端·php
m0_748255023 小时前
前端常用算法集合
前端·算法
真的很上进3 小时前
如何借助 Babel+TS+ESLint 构建现代 JS 工程环境?
java·前端·javascript·css·react.js·vue·html
web130933203983 小时前
vue elementUI form组件动态添加el-form-item并且动态添加rules必填项校验方法
前端·vue.js·elementui
NiNg_1_2343 小时前
Echarts连接数据库,实时绘制图表详解
前端·数据库·echarts
如若1234 小时前
对文件内的文件名生成目录,方便查阅
java·前端·python
滚雪球~5 小时前
npm error code ETIMEDOUT
前端·npm·node.js
沙漏无语5 小时前
npm : 无法加载文件 D:\Nodejs\node_global\npm.ps1,因为在此系统上禁止运行脚本
前端·npm·node.js
supermapsupport5 小时前
iClient3D for Cesium在Vue中快速实现场景卷帘
前端·vue.js·3d·cesium·supermap
brrdg_sefg5 小时前
WEB 漏洞 - 文件包含漏洞深度解析
前端·网络·安全