我終於學會了黑客帝國裡的矩陣雨

語言: CN / TW / HK

相信大家都對黑客帝國電影裡的矩陣雨印象非常深刻,效果非常酷炫,我看了一下相關實現庫的程式碼,也非常簡單,核心就是用好命令列的控制字元,這裡分享一下。

matrix-rain 的原始碼中,總共只有兩個檔案,ansi.jsindex.js,非常小巧。

控制字元和控制序列

ansi.js 中定義了一些命令列的操作方法,也就是對控制字元做了一些方法封裝,程式碼如下:

``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-bit24-bit。程式碼中也有使用樣例,這裡不再贅述了。

矩陣渲染

在 matrix-rain 的程式碼中,index.js 裡的核心功能是 MatrixRain 這個類:

``js 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.colorsfg${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();

} } ```

還有幾個工具方法:

```js // 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 的啟動程式碼如下:

```js 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 配合使用來開發命令列應用程式。