乐趣区

关于源码分析:我终于学会了黑客帝国中的矩阵雨

置信大家都对黑客帝国电影里的矩阵雨印象十分粗浅,就是上面这个成果。

成果十分酷炫,我看了一下相干实现库的代码,也非常简单,外围就是用好命令行的控制字符,这里分享一下。

在 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 A 向下挪动 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 函数一把更新。

常见面试知识点、技术解决方案、教程,都能够扫码关注公众号“众里千寻”获取,或者来这里 https://everfind.github.io。

让咱们一起成长~

退出移动版