Error message here!

Hide Error message here!

忘记密码?

Error message here!

请输入正确邮箱

Hide Error message here!

密码丢失?请输入您的电子邮件地址。您将收到一个重设密码链接。

Error message here!

返回登录

Close

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

廣吾 2021-09-15 14:18:47 阅读数:68 评论数:0 点赞数:0 收藏数:0

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

矩陣雨

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

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

眾裏千尋

讓我們一起成長~

版权声明
本文为[廣吾]所创,转载请带上原文链接,感谢

编程之旅,人生之路,不止于编程,还有诗和远方。
阅代码原理,看框架知识,学企业实践;
赏诗词,读日记,踏人生之路,观世界之行;