console覺醒之路,打印個動畫如何?

引言

console做爲前端調試中普遍使用的成員之一,忠實擔任了明察秋毫的數據檢閱師,又默默承受了萬千bug的狂風驟雨,它log時雲淡風輕,它debug時誠如明鏡,它info時溫柔細膩,它warn時憋黃了臉,它error時急紅了眼,它咆哮,它又彷徨。 css

有人站出來了,說:「console,給你個兼職吧。」

因而它被安排在了首頁廣場上,在衆人的注視下一動不動,高舉着僱主的橫幅:html

放下橫幅,一切又變得索然無味。前端

吶,不如,我來幫幫你吧。node


小試

「console,讓我給你摸摸骨。」 git

「嗯,不錯,自帶點陣圖基因。」

我摸起沒有鬍鬚的下巴說道。github

console.log('%c你%c說%c什麼%c?', 'background: #000; color: #fff','color: blue','color: red; border-bottom: 1px solid red','background: blue; color: #fff; border-radius: 50%;');
複製代碼

「喲呵,還蘊藏樣式變化!」算法

「這是我前兩天手寫的××寶典,我看你骨骼清奇,不要998,只要9塊8,包郵免費寄到家!」canvas

cosole聽完遲疑了,而我已經來不及收手了。數組


構建

【忽然一本正經臉】bash

咱們知道,二維圖像微觀上是由一個個精巧排布的不一樣顏色的像素點組成,而動畫則是這些像素點不斷按規律地變換樣式顏色,加上人眼的視覺暫留現象,從而造成看上去連貫的動畫。

console.log 很顯然能知足拼湊出圖像所須要的條件:

  • 一個字符的打印位置即可以對應一個像素點的排列位置
  • 一個字符的css樣式能夠對應一個像素點的顏色樣式

而讓這圖像裏的內容動起來,能夠像canvas那樣啓用「渲染」,每次「渲染」時先使用console.clear()清除掉上一次打印出的字符,而後計算場景中需移動的字符本次所在的位置,打印出字符到該位置,必定時間間隔後進行下一次「渲染」操做。

爲了實現上述效果,須要構建出console眼中的二維世界觀。

一個完整的二維圖像,能夠由若干個子圖像組成,即元素(element),例如這個糊一臉井號的心💗:

## ##
#### ####
 #######
  #####
    #
複製代碼

將它放入圖像場景中(scene)中,它便擁有在該場景中的位置屬性。

同時多個element也能夠放入一個組合(group)中,組合再放入場景,組合裏的元素便相對於該組合計算位置,便可以隨着組合總體而移動位置,見下圖:

scene與group均是圖像容器,只有element是攜帶了子圖像信息的實體。

接着,要想把場景中的全部內容按照他們本身的位置與樣式打印出來,就得須要渲染器(renderer)了,renderer將場景中的element逐個取出,計算出其對應的絕對位置座標後,將element包含的像素信息,一一映射到renderer所維護的二維數組canvas中;該過程結束後,獲得的canvas即是該場景包含的全部圖像信息,打印它到屏幕上,顯示本次圖像的任務就完成了。

社會主義核心價...哦不對,console版二維世界觀便如上所述,接下來開始擼代碼。


實現

先取個名字吧:ConsoleCanvas

1. 實現場景類 Scene

Scene自己做爲容器,屬性中的elements維護着該場景中包含的元素,場景的add方法用於向場景中添加元素,若添加的是組合,則會提取出組合裏的元素放入場景。

window.ConsoleCanvas = new function() {
    // 場景
    this.Scene = function(name = '', style) {
        // 場景元素集合
        this.elements = [];
        // 場景樣式
        this.style = Object.prototype.toString.call(style) === '[object Array]' ? style : [];
        // 場景名稱
        this.name = name.toString();
    };
    // 場景添加元素或組合
    this.Scene.prototype.add = function(ele) {
        if (!ele) {
            return;
        }
        ele.belong = this;
        // 添加的元素是組合元素
        if (ele.isGroup) {
            // 提出組合裏的元素納入場景
            this.elements.push(...ele.elements);
            return;
        }
        this.elements.push(ele);
    };
    
    /* 後續代碼塊均承接此處 */
    
}
複製代碼

2. 實現元素類Element

//vals:元素字符內容,style:元素樣式,z_index:層疊優先級,position:位置
this.Element = function(vals = [[]], style = [], z_index = 1, position) {
    // 元素隨機id
    this.id = Number(Math.random().toString().substr(3, 1) + Date.now()).toString(36);
    this.vals = vals;
    this.style = style;
    this.z_index = z_index;
    // 元素縮放值
    this.scale_x = 1;
    this.scale_y = 1;
    this.position = {
        x: position && position.x ? position.x : 0,
        y: position && position.y ? position.y : 0
    },
    // 元素所屬的組合
    this.group = null;
    // 元素所屬的場景
    this.belong = null;
};
複製代碼

元素中的vals屬性是一個二維數組,保存着該元素的點陣圖,例如以前的心形會以以下形態保存在vals中:

this.vals = [
    [' ','#','#',' ',' ',' ','#','#'],
    ['#','#','#','#',' ','#','#','#','#'],
    [' ','#','#','#','#','#','#','#'],
    [' ',' ','#','#','#','#','#'],
    [' ',' ',' ',' ','#']
];
複製代碼

Element類添加操做方法:

  • clone,複製元素,這裏只是簡單複製了valsstylez_indexposition等信息來生成新元素。
// 元素克隆
this.Element.prototype.clone = function() {
    return new this.constructor(JSON.parse(JSON.stringify(this.vals)), this.style.concat(), this.z_index, this.position);
};
複製代碼
  • remove,從場景中刪除元素自身:
// 元素刪除
this.Element.prototype.remove = function() {
    // 獲取元素所屬場景
    let scene = this.group ? this.group.belong : this.belong;
    // 根據元素id從場景中查詢到該元素index
    let index = scene.elements.findIndex((ele) => {
        return ele.id === this.id;
    });
    if (index >= 0) {
        // 從場景中去除該元素項
        scene.elements.splice(index, 1);
    }
};
複製代碼
  • width,獲取或者設置元素的最小包圍盒的寬度:
// 元素獲取寬度或者設置寬度(裁剪寬度)
this.Element.prototype.width = function(width) {
    width = parseInt(width);
    if (width && width > 0) {
        // 設置寬度,只用於裁剪,拓寬無效
        for (let j = 0; j < this.vals.length; j++) {
            this.vals[j].splice(width);
        }
        return width;
    } else {
        // 獲取寬度
        return Math.max.apply(null, this.vals.map((v) => {
            return v.length;
        }));
    }
};
複製代碼
  • height,獲取或者設置元素的最小包圍盒的高度:
// 元素獲取高度或者設置高度(裁剪高度)
this.Element.prototype.height = function(height) {
    height = parseInt(height);
    if (height && height > 0) {
        // 設置高度,只用於裁剪,拓高無效
        this.vals.splice(height);
        return height;
    } else {
        // 獲取高度
        return this.vals.length;
    }
};
複製代碼
  • scaleX,每一個像素都要根據縮放值遷移下位置,爲了不先縮小再放大會出現的失真狀況,隱藏保留了元素字符圖案的原始副本,每次縮放都根據原始圖案來操做。
// 元素橫座標縮放
this.Element.prototype.scaleX = function(multiple, flag) {
    let i, j;
    let scaleY = this.scale_y;
    multiple = +multiple;
    if (this.valsCopy) {
        // 每次變換使用原始圖案進行
        this.vals = JSON.parse(JSON.stringify(this.valsCopy));
    } else {
        // 首次使用時保存原圖案副本
        this.valsCopy = JSON.parse(JSON.stringify(this.vals));
    }
    if (!flag) {
        // 使用原始圖案從新縮放縱座標(避免失真),flag用於避免循環嵌套
        this.scaleY(this.scale_y, true);
    }
    if (multiple < 1) {
        for (j = 0; j < this.vals.length; j++) {
            for (i = 0; i < this.vals[j].length; i++) {
                [this.vals[j][Math.ceil(i * multiple)], this.vals[j][i]] = [this.vals[j][i], ' '];
            }
        }
        // 裁去縮小後的多餘部分
        for (j = 0; j < this.vals.length; j++) {
            this.vals[j].splice(Math.ceil(this.vals[j].length * multiple));
        }
        this.scale_x = multiple;
    } else if (multiple > 1) {
        for (j = 0; j < this.vals.length; j++) {
            for (i = this.vals[j].length - 1; i > 0; i--) {
                [this.vals[j][Math.ceil(i * multiple)], this.vals[j][i]] = [this.vals[j][i], ' '];
            }
        }
        // 填充放大後的未定義像素
        for (j = 0; j < this.vals.length; j++) {
            for (i = this.vals[j].length - 1; i > 0; i--) {
                if (this.vals[j][i] === undefined) {
                    this.vals[j][i] = ' ';
                }
            }
        }
        this.scale_x = multiple;
    } else {
        this.scale_x = 1;
        return;
    }
};
複製代碼
  • scaleY,原理同scaleX,區別在於scaleX逐行遍歷遷移像素,而scaleY是逐列遍歷遷移像素。
// 元素縱座標縮放
this.Element.prototype.scaleY = function(multiple, flag) {
    let i, j;
    multiple = +multiple;
    if (this.valsCopy) {
        // 每次變換使用原始圖案
        this.vals = JSON.parse(JSON.stringify(this.valsCopy));
    } else {
        // 首次使用時保存原圖案副本
        this.valsCopy = JSON.parse(JSON.stringify(this.vals));
    }
    if (!flag) {
        // 使用原始圖案從新縮放橫座標(避免失真),flag用於避免循環嵌套
        this.scaleX(this.scale_x, true);
    }
    let length = this.width();
    if (multiple < 1) {
        for (i = 0; i < length; i++) {
            for (j = 0; j < this.vals.length; j++) {
                [this.vals[Math.floor(j * multiple)][i], this.vals[j][i]] = [this.vals[j][i], ' '];
            }
        }
        // 裁去縮小後的多餘部分
        this.vals.splice(Math.ceil(this.vals.length * multiple));
        for (j = 0; j < this.vals.length; j++) {
            for (i = 0; i < this.vals[j].length; i++) {
                if (this.vals[j][i] === undefined) {
                    this.vals[j].splice(i);
                    break;
                }
            }
        }
        this.scale_y = multiple;
    } else if (multiple > 1) {
        let colLength = this.vals.length;
        for (i = 0; i < length; i++) {
            for (j = colLength - 1; j >= 0; j--) {
                if (!this.vals[Math.floor(j * multiple)]) {
                    // 開闢新數組空間
                    this.vals[Math.floor(j * multiple)] = [];
                }
                [this.vals[Math.floor(j * multiple)][i], this.vals[j][i]] = [this.vals[j][i], ' '];
            }
        }
        // 填充放大後的未定義像素
        for (j = 0; j < this.vals.length; j++) {
            if (this.vals[j]) {
                for (i = 0; i < this.vals[j].length; i++) {
                    if (this.vals[j][i] === undefined) {
                        this.vals[j].splice(i);
                        break;
                    }
                }
            } else {
                this.vals[j] = [' '];
            }
        }
        this.scale_y = multiple;
    } else {
        this.scale_y = 1;
        return;
    }
};
複製代碼
  • scale,同時縮放元素橫座標與縱座標:
// 元素縮放
this.Element.prototype.scale = function(x, y) {
    this.scaleX(+x);
    this.scaleY(+y);
};
複製代碼

3. 實現組合類Group

// 元素組合
this.Group = function() {
    // 組合標誌
    this.isGroup = true;
    // 存放的子元素
    this.elements = [];
    // 組合位置
    this.position = {
        x: 0,
        y: 0
    };
    // 組合層疊優先級
    this.z_index = 0;
};
複製代碼

Group添加方法:

  • add,往組合裏添加元素:
// 組合添加子元素
this.Group.prototype.add = function(ele) {
    if (ele) {
    // 以數組形式添加多個子元素
    if (Object.prototype.toString.call(ele) === '[object Array]') {
        ele.forEach((item) => {
            this.elements.push(item);
            item.group = this;
        });
        return;
    }
    // 添加單個子元素
    this.elements.push(ele);
    ele.group = this;
    }
};
複製代碼
  • remove,刪除整個組合,即刪除組合裏包含的全部元素:、
// 刪除組合
this.Group.prototype.remove = function() {
    this.elements.forEach((ele) => {
        ele.remove();
    })
};
複製代碼

4. 實現渲染器類Renderer

// 渲染器
this.Renderer = function() {
   this.width = 10;
   this.height = 10;
   this.canvas = [];
};
複製代碼

Renderer添加方法:

  • Pixel,生成用於渲染的像素點:
// 生成用於渲染的像素點
this.Renderer.prototype.Pixel = function() {
   // 字符值
   this.val = ' ';
   // 樣式數組值
   this.style = [];
   // 層疊優先級
   this.z_index = 0;
};	
複製代碼
  • setSize,設置渲染尺寸,即按尺寸大小開闢二維數組canvas的空間。
// 設置渲染畫布的尺寸
this.Renderer.prototype.setSize = function(width, height) {
    this.width = parseInt(width);
    this.height = parseInt(height);
    this.canvas = [];
    for (let j = 0; j < height; j++) {
        this.canvas.push(new Array(width));
            for (let i = 0; i < width; i++) {
                this.canvas[j][i] = new this.Pixel();
            }
    }
};
複製代碼
  • clear,清除畫布:
// 清除畫布
// x:開始清除的橫座標,y:開始清除的縱座標,width:清除寬度,height:清除長度
this.Renderer.prototype.clear = function(x = 0, y = 0, width, height) {
    width = parseInt(width ? width : this.width);
    height = parseInt(height ? height : this.height);
    for (let j = y; j < y + height && j < this.height; j++) {
        for (let i = x; i < x + width && i < this.width; i++) {
            this.canvas[j][i].val = ' ';
            this.canvas[j][i].style = [];
            this.canvas[j][i].z_index = 0;
        }
    }
    // console清屏
    console.clear();
};
複製代碼
  • print,帶樣式逐行打印canvas中的字符內容,在行數較多的狀況下,逐行打印會引發很明顯的屏幕閃爍。爲何不能一次性打印所有?嘗試過了,在帶%c帶樣式使用console.log時,換行符放其中並不能按想像中的那樣換行,以下圖:

// 帶樣式打印字符,逐行打印呈現畫布帶樣式的內容
// noBorder:不顯示左右邊框(默認顯示)
this.Renderer.prototype.print = function(noBorder) {
    let row = '';
    let rowId = 0;
    let style = [];
    let borderRight = noBorder ? '' : 'border-left: 1px solid #ddd';
    let borderLeft = noBorder ? '' : 'border-right: 1px solid #ddd';
    for (let j = 0; j < this.canvas.length; j++) {
        row = noBorder ? '' : '%c ';
        // 每行的惟一id,避免console打印出一樣的字符會堆疊顯示
        rowId = '%c' + j;
        style = noBorder ? [] : [borderLeft];
        for (let i = 0; i < this.canvas[j].length; i++) {
            row += '%c' + this.canvas[j][i].val;
            style.push(this.canvas[j][i].style.join(';'));
        }
        style.push(`background: #fff; color: #fff;${borderRight}`);
        console.log(row + rowId, ...style);
    }
};
複製代碼
  • printNoStyle,爲優化以前的明顯閃屏狀況,提供一次性打印不帶樣式的字符內容的方法。
// 不帶樣式打印字符,一次打印呈現畫布不帶樣式的內容
// noBorder:不顯示左右邊框(默認顯示)
this.Renderer.prototype.printNoStyle = function(noBorder) {
    let row = '';
    let rows = '';
    let border = noBorder ? '' : '|';
    for (let j = 0; j < this.canvas.length; j++) {
        row = border;
        for (let i = 0; i < this.canvas[j].length; i++) {
            row += this.canvas[j][i].val;
        }
        rows += row + border + '\n';
    }
    console.log(rows);
};
複製代碼
  • render,計算場景元素映射到canvas上後的像素狀況,而後調用打印方法渲染出場景內容呈現於控制檯上。
// 畫布渲染
// scene:用於渲染的場景,noStyle:不帶樣式(默認帶樣式),noBorder:不帶左右邊框(默認帶邊框)
this.Renderer.prototype.render = function(scene, noStyle, noBorder) {
    // 先清屏
    this.clear();
    // 逐個取出場景中的元素,計算位置後取值替換畫布的對應的像素點
    scene.elements.forEach((ele, i) => {
        let style = ele.style.concat();
        let z_index = ele.z_index;
        let positionY = Math.floor(ele.position.y);
        let positionX = Math.floor(ele.position.x);
        if (ele.group) {
            // 從組合裏的相對座標轉換爲畫布上的絕對座標
            positionY += ele.group.position.y;
            positionX += ele.group.position.x;
            // 疊加上組合的層疊優先級
            z_index += ele.group.z_index;
        }
        for (let y = positionY; y < positionY + ele.vals.length; y++) {
            if (y >= 0 && y < this.height) {
                for (let x = positionX; x < positionX + ele.vals[y - positionY].length && x < this.width; x++) {
                    if (x >= 0 && x < this.width) {
                        // 層疊優先級大的元素會覆蓋優先級小的元素
                        if (z_index >= this.canvas[y][x].z_index && ele.vals[y - positionY][x - positionX] && ele.vals[y - positionY][x - positionX].toString().trim() != '') {
                            this.canvas[y][x].val = ele.vals[y - positionY][x - positionX];
                            this.canvas[y][x].style = style.concat();
                            this.canvas[y][x].z_index = z_index;
                        }
                    }
                }
            }
        }
    });
    // 打印樣式或無樣式判斷
    noStyle ? this.printNoStyle(noBorder) : this.print(noBorder);
}
複製代碼

耍一波

有了上面的類庫,寫一個console版的彈球動畫就容易多了:

// 彈球動畫
class PinBall {
    constructor(width = 30, height = 10) {
    // 建立場景
    this.scene = new ConsoleCanvas.Scene();
    // 建立渲染器
    this.renderer = new ConsoleCanvas.Renderer();
    // 設置尺寸
    this.renderer.setSize(width, height);
    // 場景元素添加
    this.elementAdd();
    // 開始動畫循環
    this.loop();
    }
    elementAdd() {
        // 建立小球元素
        this.ball = new ConsoleCanvas.Element([['●']], ['background: blue', 'color: blue', 'border-radius: 50%']);
        // 在上半區域隨機小球起始座標
        this.ball.position.x = Math.floor(Math.random() * this.renderer.width);
        this.ball.position.y = Math.floor(Math.random() * this.renderer.height / 2);
        this.scene.add(this.ball);
    }
    animation() {
        let gap = 1;
        this.ball.kx = this.ball.kx ? this.ball.kx : 1;
        this.ball.ky = this.ball.ky ? this.ball.ky : 1;
        let x = this.ball.position.x + this.ball.kx * gap;
        let y = this.ball.position.y + this.ball.ky * gap;
        // 觸碰邊界時回彈
        if (x > this.renderer.width - this.ball.vals[0].length || x < 0) {
            this.ball.kx = -1 * this.ball.kx;
        }
        if (y > this.renderer.height - this.ball.vals.length || y < 0) {
            this.ball.ky = -1 * this.ball.ky;
        }
        this.ball.position.x = this.ball.position.x + (this.ball.kx * gap);
        this.ball.position.y = this.ball.position.y + (this.ball.ky * gap);
    }
    loop() {
        this.renderer.render(this.scene, true);
        this.animation();
        setTimeout(() => {
            this.loop();
        }, 300);
    }
}
let pinBall = new PinBall(30, 10);
複製代碼

帶樣式版本的彈球效果以下,能夠發現,帶樣式的逐行打印,刷新頻率的確捉膝見肘,時間間隔再調小點怕是會閃瞎了個人眼:

那再看看去樣式的版本,無樣式總體單次打印起來,就不會那麼閃屏了:

要不,再加點鍵盤交互?

將求解Hanoi塔問題的遞歸結果可視化,如下爲3個圓盤時候的執行狀況(四、5個的時候的也有,太長了不放了):


拓展

什麼❓你說仍是有些閃?今晚的星星✨也有些閃?

我嘆嘆氣,轉身摸摸console的頭:「請把寶典還我...」

因而乎,我修改了Renderer類的print函數,將渲染的內容輸出到了html結構中:

// 輸出像素字符到指定dom
// target:目標dom, noStyle: 不顯示樣式, noBorder: 不顯示左右邊框
this.Renderer.prototype.print = function(target, noStyle, noBorder) {
    let row = '';
    let style = [];
    let rows = '';
    let border = noBorder ? '' : '<span>|</span>';
    for (let j = 0; j < this.canvas.length; j++) {
        row = border;
        style = [];
        for (let i = 0; i < this.canvas[j].length; i++) {
            row += `<span style='${noStyle?"":this.canvas[j][i].style.join(";")}'>${this.canvas[j][i].val}</span>`;
        }
        rows += row + border + '</br>';
    }
    if (target) {
        target.innerHTML = rows;
    }
};
複製代碼

修改的版本命名爲PixelCanvas

而後樣式也能夠輕鬆帶了:

加快速度渲染一口氣上五樓也絕不費力了呢~


終章

「感受像是回到了紅白機時代...」

我正沉醉於自言自語,擡頭卻看到console臉上閃爍着豁達的微笑。

實現代碼及動畫實例代碼的地址見下:

https://github.com/youngdro/ConsoleCanvas

一鍵傳送【求star臉 (*゚ー゚)v】

Hanoi塔demo在線預覽

若是要問我爲何想到要寫這個東西,以及它能有什麼大用途,我怕是會無語凝噎。

生活到處有樂趣,代碼亦同。

誠摯但願各位看官能從中找到屬於本身的小樂趣~

·

·

·

想看我更多的段子帖,可移步如下地址:
複製代碼

node基金爬蟲,自導自演瞭解一下?

原創佛系紅包算法,瞭解一下?

相關文章
相關標籤/搜索