雑食性雑感雑記

知識の整理場。ため込んだ知識をブログ記事として再構築します。

【JavaScript & Canvas】一定間隔で更新する描画を作ってみる。(ライフゲーム)

前回まではベストエフォートで常に更新処理終わったら描画の更新をかける方針でした。
そうではなく「一定のタイミングで更新」かける方式の描画についても試してみます。

ライフゲームはよく作っていて分かりやすいのでこれで。

作成物




ライフゲームについてはたくさん解説記事あると思うので割愛。
色が付いているのは、3つのレイヤのライフゲームが同時に動いていて、その重なりによって色を変えているからです。
レイヤ同士が相互作用起こすルールは入ってないので、通常のライフゲーム以上の複雑な動きはしませんが、それなりに色が変わって面白いかも?

技術要素

処理待ち

ベースとなる部分は同じにして、ここに「設定処理時間よりも速く処理が終わったら待つ」処理を入れます。
元のコードは

class CanvasOp {
    ( 中略 )

    update(timestamp) {
        ( 中略。更新処理。)

        // 次フレームへ
        window.requestAnimationFrame((timestamp) => this.update(timestamp));
    }
}

let canvas;
window.onload = function()
{
    canvas = new CanvasOp(WIDTH, HEIGHT, CELL_SIZE, LINE_WIDTH, H_CELL_COUNT, V_CELL_COUNT, BASE_ID);
    canvas.update(0);
}

という感じでした。

処理待ちに関しては、Promise と setTimeout を組み合わせると実現できるそうなのでこれを採用。
また、時間計測も必要で、これは performance が最近の Javascript で使えるようなのでこちらを使います。
await キーワードが入るとその関数には async を付ける必要があるとのこと。
( C# みたいに await - async 地獄になるかと思ったけど、そういうわけでは無いらしい?あまり調べてない。)

最終形は次の通り。

class CanvasOp {
    ( 中略 )

    async update(timestamp) {

        // 計測開始
        const start = performance.now();

        ( 更新処理 )

        // 計測終了
        const end = performance.now();

        // 1フレーム分処理が設定時間を割っていたら、それだけ待つ
        const elapsed = end - start;
        if (elapsed < WAITING_MS) {
            const sleep = ms => new Promise(res => setTimeout(res, ms));
            await sleep(WAITING_MS - elapsed);
        }

        // 次フレームへ
        window.requestAnimationFrame((timestamp) => this.update(timestamp));
    }
}

let canvas;
window.onload = function()
{
    canvas = new CanvasOp(WIDTH, HEIGHT, CELL_SIZE, LINE_WIDTH, H_CELL_COUNT, V_CELL_COUNT, BASE_ID);
    canvas.update(0);
}

ライフゲーム

ライフゲームの実装は
・Field ... 線やセルの描画を管理
・LifeGameCells ... 盤面のセルの動きの管理。隣接数の計算とか次フレームの生死の決定とか。
・Game ... 全体の管理。
という3つのクラスで動かしています。

1つ分を LifeGameCells に閉じ込めることによって、3つのレイヤを別々に動かせることができるようになりました。

class Game {

    constructor(h_cell_count, v_cell_count) {

        // 要素
        this.h_cell_count = h_cell_count; // 横方向
        this.v_cell_count = v_cell_count; // 縦方向

        // 盤面
        // 3 Layer 用意し、有効状態によって色を切り替え
        this.cells01 = new LifeGameCells(this.h_cell_count, this.h_cell_count);
        this.cells02 = new LifeGameCells(this.h_cell_count, this.h_cell_count);
        this.cells03 = new LifeGameCells(this.h_cell_count, this.h_cell_count);

        // 配色
        this.colors = Array.from(CELL_COLORS);

        // 初期化
        this.init();
    }

    // 描画対象の座標 & 色を返す
    getPoints() {
        let points = [];
        for (let y = 0; y < this.v_cell_count; y++) {
            for (let x = 0; x < this.h_cell_count; x++) {

                let color_idx = 0;
                if (this.cells01.get(x, y) == ALIVE) color_idx += 1;
                if (this.cells02.get(x, y) == ALIVE) color_idx += 2;
                if (this.cells03.get(x, y) == ALIVE) color_idx += 4;

                if (color_idx == 0) continue;
                points.push({x: x, y: y, color: this.colors[color_idx]});                
            }
        }
        return points;
    }

    // 初期化
    init() {
        // ランダムで生死状態を設定
        this.cells01.random();
        this.cells02.random();
        this.cells03.random();
    }

    // 更新
    update() {
        // 現在の結果の隣接数を更新
        this.cells01.update();
        this.cells02.update();
        this.cells03.update();
    }
}

( コード全文は記事の一番下においたので、興味ある方はそちらを。)

レイヤ同士の相互干渉とか書けると面白そうですね。。どういう動きするとよいのだろうか……?

その他

セルのサイズ、線の太さ、セルの個数で全体サイズが決まっています。
ここでは、はてなブログ表示できるサイズになっていますが、高速処理可能ならもっと大きくしても面白いかも。

// セル1辺サイズ (pixel)
const CELL_SIZE = 6;

// 区切り線の太さ (pixel)
const LINE_WIDTH = 1;

// 横方向および縦方向のセル個数
const H_CELL_COUNT = 80;
const V_CELL_COUNT = 80;

色についてはグラデーション配色を出してくれるサイトより。

// 色設定
// 3レイヤの色を設定して、有効状態に応じて切り替える。
// 配色は https://fffuel.co/hhhue/ より
let CELL_COLORS = [];
CELL_COLORS.push(''); // 0番目は空白
CELL_COLORS.push('rgb(236,91,19)');
CELL_COLORS.push('rgb(255,140,69)');
CELL_COLORS.push('rgb(255,227,209)');
CELL_COLORS.push('rgb(19,236,200)');
CELL_COLORS.push('rgb(108,255,251)');
CELL_COLORS.push('rgb(209,255,253)');
CELL_COLORS.push('rgb(0,161,207)');

あと、待ち時間も設定値です。

// 処理待ちを行う時間 (ミリ秒)
// 更新処理がこの値より短かった場合は、その分だけ描画更新を待つ。
const WAITING_MS = 100;

コード全体

<div id="canvas_base"></div>
<div>
    <input type="button" value="Reset" onclick="resetButtonClick()" />
</div>

<script>

// Canvas 紐づけ要素 ID
const BASE_ID = "canvas_base";

// セル1辺サイズ (pixel)
const CELL_SIZE = 6;

// 区切り線の太さ (pixel)
const LINE_WIDTH = 1;

// 横方向および縦方向のセル個数
const H_CELL_COUNT = 80;
const V_CELL_COUNT = 80;

// 処理待ちを行う時間 (ミリ秒)
// 更新処理がこの値より短かった場合は、その分だけ描画更新を待つ。
const WAITING_MS = 100;

// 描画エリアサイズ ... セル個数から計算
const WIDTH = H_CELL_COUNT * (CELL_SIZE + LINE_WIDTH) + LINE_WIDTH;
const HEIGHT = V_CELL_COUNT * (CELL_SIZE + LINE_WIDTH) + LINE_WIDTH;

// 状態
const DEAD = 0;
const ALIVE = 1;

// 色設定
// 3レイヤの色を設定して、有効状態に応じて切り替える。
// 配色は https://fffuel.co/hhhue/ より
let CELL_COLORS = [];
CELL_COLORS.push(''); // 0番目は空白
CELL_COLORS.push('rgb(236,91,19)');
CELL_COLORS.push('rgb(255,140,69)');
CELL_COLORS.push('rgb(255,227,209)');
CELL_COLORS.push('rgb(19,236,200)');
CELL_COLORS.push('rgb(108,255,251)');
CELL_COLORS.push('rgb(209,255,253)');
CELL_COLORS.push('rgb(0,161,207)');

////////////////////
//
// 描画フィールド操作クラス
// 格子盤に円形を配置
//
class Field {

    constructor(context, width, height, cell_size, line_width, h_cell_count, v_cell_count) {
        // Canvas 操作用 Context
        this.context = context

        // Canvas サイズ
        this.width = width;
        this.height = height;

        // 要素サイズ
        this.cell_size = cell_size;

        // 境界線幅
        this.line_width = line_width;

        // 水平方向要素数
        this.h_cell_count = h_cell_count;

        // 垂直方向要素数
        this.v_cell_count = v_cell_count;

        // 計算用
        this.pos_offset = this.line_width + this.cell_size / 2.0; // 要素円形描画の開始位置
        this.pos_step = this.line_width + this.cell_size; // 格子や要素描画の移動幅
        this.arc_radius = this.cell_size / 2.0;

        // 格子色
        this.lattice_color = 'rgb(211, 211, 211)';
    }

    // Image data
    getImageData() {
        return this.context.getImageData(0, 0, this.width, this.height);
    }

    // 表示更新
    update(points) {

        // Clear
        this.context.clearRect(0, 0, this.width, this.height);

        // 格子描画
        this.drawLattice();

        // 点描画
        this.drawPoints(points);
    }


    // 格子描画
    drawLattice() {

        this.context.beginPath();
        this.context.strokeStyle = this.lattice_color;
        // 縦
        for (let x = 0; x <= this.h_cell_count; x++) {
            const pos = x * this.pos_step;
            this.context.moveTo(pos, 0);
            this.context.lineTo(pos, this.height);
        }

        // 横
        for (let y = 0; y <= this.v_cell_count; y++) {
            const pos = y * this.pos_step;
            this.context.moveTo(0, pos);
            this.context.lineTo(this.width, pos);
        }

        this.context.stroke();
    }

    // 点の描画
    drawPoints(points) {

        points.forEach(elem => {
            this.context.beginPath();
            this.context.fillStyle = elem.color;
            const pos_x = this.pos_offset + this.pos_step * elem.x;
            const pos_y = this.pos_offset + this.pos_step * elem.y;
            this.context.arc(pos_x, pos_y, this.arc_radius, 0, 2 * Math.PI, false);
            this.context.fill();
        });
    }
}


////////////////////
// 
// Life game セル操作
//
class LifeGameCells {

    constructor(h_cell_count, v_cell_count) {

        // 要素
        this.h_cell_count = h_cell_count; // 横方向
        this.v_cell_count = v_cell_count; // 縦方向

        // 盤面の状態保持
        this.cells = new Array(this.h_cell_count * this.v_cell_count);       // 現フレーム
        this.next_cells = new Array(this.h_cell_count * this.v_cell_count);  // 次フレーム

        // 周辺の生存個数カウント
        this.neighborhood = new Array(this.h_cell_count * this.v_cell_count);

        // 初期化
        this.init();
    }

    // 現状態を 0 = DEAD で初期化
    init() {
        for (let y = 0; y < this.v_cell_count; y++) {
            for (let x = 0; x < this.h_cell_count; x++) {
                this.set(x, y, DEAD);
            }
        }
    }

    // 次状態を 0 = DEAD で初期化
    initNext() {
        for (let y = 0; y < this.v_cell_count; y++) {
            for (let x = 0; x < this.h_cell_count; x++) {
                this.setNext(x, y, DEAD);
            }
        }
    }

    // ランダムで DEAD or ALIVE 設定
    random() {
        for (let y = 0; y < this.v_cell_count; y++) {
            for (let x = 0; x < this.h_cell_count; x++) {
                this.set(x, y, Math.floor(Math.random() * 2)); // 0 or 1
            }
        }
    }

    // 次フレーム情報更新
    update() {
        // 次フレーム状態更新
        this.initNext();

        // 周辺情報更新
        this.updateNeighborhood();

        // 情報更新
        for (let y = 0; y < this.v_cell_count; y++) {
            for (let x = 0; x < this.h_cell_count; x++) {

                // DEAD 状態時
                if (this.get(x, y) == DEAD) {
                    if (this.getNeighborhood(x, y) == 3) {
                        this.setNext(x, y, ALIVE);
                    }
                }
                // ALIVE 状態時
                else {
                    const n = this.getNeighborhood(x, y);
                    if (n == 2 || n == 3) {
                        this.setNext(x, y, ALIVE);
                    }
                }
            }
        }

        // 次フレーム → 現フレーム
        this.cells = Array.from(this.next_cells);
    }

    // 自身の周辺の個数をカウントする。
    updateNeighborhood() {
        for (let y = 0; y < this.v_cell_count; y++) {
            for (let x = 0; x < this.h_cell_count; x++) {

                let nx2, ny2;
                let count = 0;
                for (let ny = y - 1; ny <= y + 1; ny++) {
                    for (let nx = x - 1; nx <= x + 1; nx++) {
                        if (nx == x && ny == y) continue;
                        nx2 = nx; ny2 = ny;

                        // Loop
                        if (ny2 < 0) ny2 += this.v_cell_count;
                        ny2 %= this.v_cell_count;
                        if (nx2 < 0) nx2 += this.h_cell_count;
                        nx2 %= this.h_cell_count;

                        if (this.get(nx2, ny2) == ALIVE) count++;
                    }
                }
                this.setNeighborhood(x, y, count);
            }
        }
    }

    // (x, y) の状態を取得
    get(x, y) {
        return this.cells[x + y * this.h_cell_count];
    }

    // (x, y) の状態を設定
    set(x, y, value) {
        this.cells[x + y * this.h_cell_count] = value;
    }

    // (x, y) の次フレーム状態を取得
    getNext(x, y) {
        return this.next_cells[x + y * this.h_cell_count];
    }

    // (x, y) の次フレーム状態を設定
    setNext(x, y, value) {
        this.next_cells[x + y * this.h_cell_count] = value;
    }    

    // (x, y) の周辺カウント数を取得
    getNeighborhood(x, y) {
        return this.neighborhood[x + y * this.h_cell_count] 
    }

    // (x, y) の周辺カウント数を設定
    setNeighborhood(x, y, value) {
        this.neighborhood[x + y * this.h_cell_count] = value;
    }
}


////////////////////
//
// Life game 処理
//
class Game {

    constructor(h_cell_count, v_cell_count) {

        // 要素
        this.h_cell_count = h_cell_count; // 横方向
        this.v_cell_count = v_cell_count; // 縦方向

        // 盤面
        // 3 Layer 用意し、有効状態によって色を切り替え
        this.cells01 = new LifeGameCells(this.h_cell_count, this.h_cell_count);
        this.cells02 = new LifeGameCells(this.h_cell_count, this.h_cell_count);
        this.cells03 = new LifeGameCells(this.h_cell_count, this.h_cell_count);

        // 配色
        this.colors = Array.from(CELL_COLORS);

        // 初期化
        this.init();
    }

    // 描画対象の座標 & 色を返す
    getPoints() {
        let points = [];
        for (let y = 0; y < this.v_cell_count; y++) {
            for (let x = 0; x < this.h_cell_count; x++) {

                let color_idx = 0;
                if (this.cells01.get(x, y) == ALIVE) color_idx += 1;
                if (this.cells02.get(x, y) == ALIVE) color_idx += 2;
                if (this.cells03.get(x, y) == ALIVE) color_idx += 4;

                if (color_idx == 0) continue;
                points.push({x: x, y: y, color: this.colors[color_idx]});                
            }
        }
        return points;
    }

    // 初期化
    init() {
        // ランダムで生死状態を設定
        this.cells01.random();
        this.cells02.random();
        this.cells03.random();
    }

    // 更新
    update() {
        // 現在の結果の隣接数を更新
        this.cells01.update();
        this.cells02.update();
        this.cells03.update();
    }
}


////////////////////
//
// Canvas 操作クラス
//
class CanvasOp {

    constructor(width, height, cell_size, line_width, h_cell_count, v_cell_count, base_id = null) {

        this.width = width;
        this.height = height;

        // 描画用 Canvas およびダブルバッファリング用 Canvas を作成
        this.canvas = document.createElement("canvas");
        this.canvas_buff = document.createElement("canvas");     
        this.canvas_buff.hidden = true;
        
        // 情報表示用
        this.text = document.createElement("div");

        // サイズ設定
        this.canvas.width = width;
        this.canvas.height = height;
        this.canvas_buff.width = width;
        this.canvas_buff.height = height;

        // canvas を親要素に紐づけ
        if (base_id == null) {
            // 未指定なら document.body に紐づける
            document.body.appendChild(this.canvas);
            document.body.appendChild(this.canvas_buff);
            document.body.appendChild(this.text);
        }
        else {
            const base = document.getElementById(base_id);
            base.appendChild(this.canvas);
            base.appendChild(this.canvas_buff);        
            base.appendChild(this.text);
        }

        // 操作用 Context
        if (this.canvas.getContext && this.canvas_buff.getContext) {
            this.context = this.canvas.getContext("2d");
            this.context_buff = this.canvas_buff.getContext("2d");
        }
        else {
            this.context = null;
            this.context_buff = null;
            console.error("Error: Canvas not supported.");
        }

        // Life game 制御
        this.game = new Game(h_cell_count, v_cell_count);
        this.game.init();

        // 描画用フィールド
        this.field = new Field(this.context_buff, width, height, cell_size, line_width, h_cell_count, v_cell_count);

        // リセット処理用フラグ
        this.reset_flag = false;
    }

    // リセット
    reset() {
        this.reset_flag = true;
    }

    // 更新処理
    async update(timestamp)
    {
        // context 空なら停止
        if (this.context == null) {
            return;
        }

        // 処理開始
        const start = performance.now();

        //////
        /// buff に描画
        ///
        if (this.reset_flag) {
            // リセット
            this.game.init();
            this.reset_flag = false;
        }
        else {
            // Life game 更新
            this.game.update();
        }
        // 表示更新
        this.field.update(this.game.getPoints());

        //////
        /// Canvas に転送
        ///
        this.context.putImageData(this.field.getImageData(), 0, 0);

        // 処理終了
        const end = performance.now();

        // 1フレーム分処理が設定時間を割っていたらそれだけ待つ
        const elapsed = end - start;
        if (elapsed < WAITING_MS) {
            const sleep = ms => new Promise(res => setTimeout(res, ms));
            await sleep(WAITING_MS - elapsed);
        }

        // Update
        window.requestAnimationFrame((timestamp) => this.update(timestamp));
    }
}


////////////////////
//
// onload
//
let canvas;
window.onload = function()
{
    canvas = new CanvasOp(WIDTH, HEIGHT, CELL_SIZE, LINE_WIDTH, H_CELL_COUNT, V_CELL_COUNT, BASE_ID);
    canvas.update(0);
}

function resetButtonClick()
{
    canvas.reset();
}

</script>


アイキャッチ用