信任大家都对黑客帝国电影里的矩阵雨印象十分深刻,作用十分酷炫,我看了一下相关完成库的代码,也十分简略,中心便是用好命令行的操控字符,这儿分享一下。

在 matrix-rain 的源代码中,一共只要两个文件,ansi.jsindex.js,十分细巧。

操控字符和操控序列

ansi.js 中界说了一些命令行的操作办法,也便是对操控字符做了一些办法封装,代码如下:

const ctlEsc = `x1b[`;
const ansi = {
  reset: () => `${ctlEsc}c`,
  clearScreen: () => `${ctlEsc}2J`,
  cursorHome: () => `${ctlEsc}H`,
  cursorPos: (row, col) => `${ctlEsc}${row};${col}H`,
  cursorVisible: () => `${ctlEsc}?25h`,
  cursorInvisible: () => `${ctlEsc}?25l`,
  useAltBuffer: () => `${ctlEsc}?47h`,
  useNormalBuffer: () => `${ctlEsc}?47l`,
  underline: () => `${ctlEsc}4m`,
  off: () => `${ctlEsc}0m`,
  bold: () => `${ctlEsc}1m`,
  color: c => `${ctlEsc}${c};1m`,
  colors: {
    fgRgb: (r, g, b) => `${ctlEsc}38;2;${r};${g};${b}m`,
    bgRgb: (r, g, b) => `${ctlEsc}48;2;${r};${g};${b}m`,
    fgBlack: () => ansi.color(`30`),
    fgRed: () => ansi.color(`31`),
    fgGreen: () => ansi.color(`32`),
    fgYellow: () => ansi.color(`33`),
    fgBlue: () => ansi.color(`34`),
    fgMagenta: () => ansi.color(`35`),
    fgCyan: () => ansi.color(`36`),
    fgWhite: () => ansi.color(`37`),
    bgBlack: () => ansi.color(`40`),
    bgRed: () => ansi.color(`41`),
    bgGreen: () => ansi.color(`42`),
    bgYellow: () => ansi.color(`43`),
    bgBlue: () => ansi.color(`44`),
    bgMagenta: () => ansi.color(`45`),
    bgCyan: () => ansi.color(`46`),
    bgWhite: () => ansi.color(`47`),
  },
};
module.exports = ansi;

这儿面 ansi 目标上的每一个办法不做过多解说了。咱们看到,每个办法都是返回一个古怪的字符串,经过这些字符串能够改变命令行的显现作用。

这些字符串其实是一个个操控字符组成的操控序列。那什么是操控字符呢?咱们应该都知道 ASC 字符集,这个字符集里边除了界说了一些可见字符以外,还有许多不可见的字符,便是操控字符。这些操控字符能够操控打印机、命令行等设备的显现和动作。

有两个操控字符集,分别是 CO 字符集和 C1 字符集。C0 字符集是 0x000x1F 这两个十六进制数规模内的字符,而 C1 字符集是 0x800x9F 这两个十六进制数规模内的字符。C0 和 C1 字符集内的字符和对应的功能能够在这儿查到,咱们不做详细描述了。

上面代码中,x1b[ 其实是一个组合,x1b 界说了 ESC 键,后跟 [ 表明这是一个操控序列导入器(Control Sequence Introducer,CSI)。在 x1b[ 后面的所有字符都会被命令行解析为操控字符。

常用的操控序列有这些:

序列 功能
CSI n A 向上移动 n(默以为 1) 个单元
CSI n B 向下移动 n(默以为 1) 个单元
CSI n C 向前移动 n(默以为 1) 个单元
CSI n D 向后移动 n(默以为 1) 个单元
CSI n E 将光标移动到 n(默以为 1) 行的下一行行首
CSI n F 将光标移动到 n(默以为 1) 行的前一行行首
CSI n G 将光标移动到当前行的第 n(默以为 1)列
CSI n ; m H
移动光标到指定方位,第 n 行,第 m 列。n 和 m 默以为 1,即 CSI ;5H 与 CSI 1;5H 等同。
CSI n J 清空屏幕。假如 n 为 0(或不指定),则从光标方位开端清空到屏幕结尾;假如 n 为 1,则从光标方位清空到屏幕最初;假如 n 为 2,则清空整个屏幕;假如 n 为 3,则不只清空整个屏幕,同时还清空滚动缓存
CSI n K 清空行,假如 n 为 0(或不指定),则从光标方位清空到行尾;假如 n 为 1,则从光标方位清空到行头;假如 n 为 2,则清空整行,光标方位不变。
CSI n S 向上滚动 n (默以为 1)行
CSI n T 向下滚动 n (默以为 1)行
CSI n ; m f CSI n ; m H 功能相同
CSI n m 设置显现作用,如 CSI 1 m 表明设置粗体,CSI 4 m 为增加下划线。

咱们能够经过 CSI n m 操控序列来操控显现作用,在设置一种显现以后,后续字符都会沿袭这种作用,直到咱们改变了显现作用。能够经过 CSI 0 m 来清楚显现作用。常见的显现作用能够在SGR (Select Graphic Rendition) parameters 查到,这儿受篇幅限制就不做赘述了。

上面的代码中,还界说了一些色彩,咱们看到色彩的界说都是一些数字,其实每一个数字都对应一种色彩,这儿列一下常见的色彩。

前景色 背景色 称号 前景色 背景色 称号
30 40 黑色 90 100 亮黑色
31 41 赤色 91 101 亮赤色
32 42 绿色 92 102 亮绿色
33 43 黄色 93 103 亮黄色
34 44 蓝色 94 104 亮蓝色
35 45 品赤色(Magenta) 95 105 亮品赤色(Magenta)
36 46 青色(Cyan) 96 106 亮青色(Cyan)
37 47 白色 97 107 亮白色

上面的代码中,运用了 CSI n;1m 的形式来界说色彩,其实是两种作用的,一个是详细色彩值,一个是加粗,一些命令行完成中会运用加粗作用来界说亮色。比方,假如直接界说 CSI 32 m 可能终究展现的是暗绿色,咱们改成 CSI 32;1m 则将显现亮绿色。

色彩支持多种格局,上面的是 3-bit 和 4-bit 格局,同时还有 8-bit 和 24-bit。代码中也有运用样例,这儿不再赘述了。

矩阵烘托

在 matrix-rain 的代码中,index.js 里的中心功能是 MatrixRain 这个类:

class MatrixRain {
  constructor(opts) {
    this.transpose = opts.direction === `h`;
    this.color = opts.color;
    this.charRange = opts.charRange;
    this.maxSpeed = 20;
    this.colDroplets = [];
    this.numCols = 0;
    this.numRows = 0;
    // handle reading from file
    if (opts.filePath) {
      if (!fs.existsSync(opts.filePath)) {
        throw new Error(`${opts.filePath} doesn't exist`);
      }
      this.fileChars = fs.readFileSync(opts.filePath, `utf-8`).trim().split(``);
      this.filePos = 0;
      this.charRange = `file`;
    }
  }
  generateChars(len, charRange) {
    // by default charRange == ascii
    let chars = new Array(len);
    if (charRange === `ascii`) {
      for (let i = 0; i < len; i++) {
        chars[i] = String.fromCharCode(rand(0x21, 0x7E));
      }
    } else if (charRange === `braille`) {
      for (let i = 0; i < len; i++) {
        chars[i] = String.fromCharCode(rand(0x2840, 0x28ff));
      }
    } else if (charRange === `katakana`) {
      for (let i = 0; i < len; i++) {
        chars[i] = String.fromCharCode(rand(0x30a0, 0x30ff));
      }
    } else if (charRange === `emoji`) {
      // emojis are two character widths, so use a prefix
      const emojiPrefix = String.fromCharCode(0xd83d);
      for (let i = 0; i < len; i++) {
        chars[i] = emojiPrefix + String.fromCharCode(rand(0xde01, 0xde4a));
      }
    } else if (charRange === `file`) {
      for (let i = 0; i < len; i++, this.filePos++) {
        this.filePos = this.filePos < this.fileChars.length ? this.filePos : 0;
        chars[i] = this.fileChars[this.filePos];
      }
    }
    return chars;
  }
  makeDroplet(col) {
    return {
      col,
      alive: 0,
      curRow: rand(0, this.numRows),
      height: rand(this.numRows / 2, this.numRows),
      speed: rand(1, this.maxSpeed),
      chars: this.generateChars(this.numRows, this.charRange),
    };
  }
  resizeDroplets() {
    [this.numCols, this.numRows] = process.stdout.getWindowSize();
    // transpose for direction
    if (this.transpose) {
      [this.numCols, this.numRows] = [this.numRows, this.numCols];
    }
    // Create droplets per column
    // add/remove droplets to match column size
    if (this.numCols > this.colDroplets.length) {
      for (let col = this.colDroplets.length; col < this.numCols; ++col) {
        // make two droplets per row that start in random positions
        this.colDroplets.push([this.makeDroplet(col), this.makeDroplet(col)]);
      }
    } else {
      this.colDroplets.splice(this.numCols, this.colDroplets.length - this.numCols);
    }
  }
  writeAt(row, col, str, color) {
    // Only output if in viewport
    if (row >=0 && row < this.numRows && col >=0 && col < this.numCols) {
      const pos = this.transpose ? ansi.cursorPos(col, row) : ansi.cursorPos(row, col);
      write(`${pos}${color || ``}${str || ``}`);
    }
  }
  renderFrame() {
    const ansiColor = ansi.colors[`fg${this.color.charAt(0).toUpperCase()}${this.color.substr(1)}`]();
    for (const droplets of this.colDroplets) {
      for (const droplet of droplets) {
        const {curRow, col: curCol, height} = droplet;
        droplet.alive++;
        if (droplet.alive % droplet.speed === 0) {
          this.writeAt(curRow - 1, curCol, droplet.chars[curRow - 1], ansiColor);
          this.writeAt(curRow, curCol, droplet.chars[curRow], ansi.colors.fgWhite());
          this.writeAt(curRow - height, curCol, ` `);
          droplet.curRow++;
        }
        if (curRow - height > this.numRows) {
          // reset droplet
          Object.assign(droplet, this.makeDroplet(droplet.col), {curRow: 0});
        }
      }
    }
    flush();
  }
}

还有几个东西办法:

// Simple string stream buffer + stdout flush at once
let outBuffer = [];
function write(chars) {
  return outBuffer.push(chars);
}
function flush() {
  process.stdout.write(outBuffer.join(``));
  return outBuffer = [];
}
function rand(start, end) {
  return start + Math.floor(Math.random() * (end - start));
}

matrix-rain 的发动代码如下:

const args = argParser.parseArgs();
const matrixRain = new MatrixRain(args);
function start() {
  if (!process.stdout.isTTY) {
    console.error(`Error: Output is not a text terminal`);
    process.exit(1);
  }
  // clear terminal and use alt buffer
  process.stdin.setRawMode(true);
  write(ansi.useAltBuffer());
  write(ansi.cursorInvisible());
  write(ansi.colors.bgBlack());
  write(ansi.colors.fgBlack());
  write(ansi.clearScreen());
  flush();
  matrixRain.resizeDroplets();
}
function stop() {
  write(ansi.cursorVisible());
  write(ansi.clearScreen());
  write(ansi.cursorHome());
  write(ansi.useNormalBuffer());
  flush();
  process.exit();
}
process.on(`SIGINT`, () => stop());
process.stdin.on(`data`, () => stop());
process.stdout.on(`resize`, () => matrixRain.resizeDroplets());
setInterval(() => matrixRain.renderFrame(), 16); // 60FPS
start();

首先初始化一个 MatrixRain 类,然后调用 start 办法。start 办法中经过 MatrixRainresizeDroplets 办法来初始化要显现的内容。

MatrixRain 类实例中管理着一个 colDroplets 数组,保存着每一列的雨滴。在 resizeDroplets 中咱们能够看到,每一列有两个雨滴。

在发动代码中咱们还能够看到,每隔 16 毫秒会调用一次 renderFrame 办法来绘制页面。而 renderFrame 办法中,会遍历每一个 colDroplet 中的每一个雨滴。因为每一个雨滴的初始方位和速度都是随机的,经过 droplet.alivedroplet.speed 的比值来确定每一次烘托的时分是否更新这个雨滴方位,从而达到每个雨滴的下落良莠不齐的作用。当雨滴现已移出屏幕可视规模后会被重置。

每一次烘托,都是经过 write 函数向大局的缓存中写入数据,之后经过 flush 函数一把更新到操控台输出。

延伸

咱们经过 CSI 操控序列能够操控屏幕中恣意方位的显现,换句话说咱们能够经过 CSI 操控序列完成在命令行或者浏览器操控台绘制图形乃至动画。

目前社区里有许多成熟的实践。

比方 chalk 这个东西,开发过命令行东西的同学应该十分了解。

还有一个脑洞很大的库,ink ,一个适用于命令行环境的 React 组件库和烘托器,能够与 React 合作运用来开发命令行应用程序。