【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>
アイキャッチ用