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


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

矩陣雨

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

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

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

眾里千尋


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM