­

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

  • 2021 年 9 月 15 日
  • 筆記

相信大家都對黑客帝國電影里的矩陣雨印象非常深刻,就是下面這個效果。

矩陣雨

效果非常酷炫,我看了一下相關實現庫的程式碼,也非常簡單,核心就是用好命令行的控制字元,這裡分享一下。

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-bit24-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 函數一把更新。

常見面試知識點、技術解決方案、教程,都可以掃碼關注公眾號「眾里千尋」獲取,或者來這裡 //everfind.github.io

眾里千尋