ライフゲームを作るの2

昨日の続き。 今日は操作性の改善として以下を実装する。

ライフゲームそのものではなく周辺ばかりだが、昨日のテスト版を使う中で俺が感じた不満は、これでほぼ解消されるはず。

そして実装結果。

昨日からの見た目の変化は二つ。

一つは、見れば判ることだが stop ボタンの追加。

start で動き出してから変化がある間はずっと動き続けるので、止めたくなった時に止めるのがこのボタン。 変化が無くなったら勝手に止まるようにしているが、振動して止まらないパターンもあるからね。

もう一つは盤面の外枠。

これが太くなっているのは、ドラッグで生死を連続設定できるようにしたことの副作用。 ドラッグしたまま盤面の外に出た時にドラッグ処理を止めたくて mouseout イベントをテーブルに設定しだのだが、テーブルがセルに完全に覆われていると、テーブル自体をターゲットとしたイベントが発火しなかった。

色々試したところ、セルに隠されないテーブルの領域がある程度あれば発火する模様。 それがボーダーでも反応してくれる。 しかし1pxとか極端に狭いのは駄目。 試行錯誤の結果、手元の環境で確実に動作する最小領域がボーダー5pxだった。

以下、ソース一式。

昨日同様、まずはスタイル定義から。

#board { border-collapse: collapse; cursor: crosshair; border: 5px solid gray; } #board td { width: 8pt; height: 8pt; font-size: 0; } #board td.dead { background: white; } #board td.alive { background: black; }

昨日との違いは border の設定のみ。

次にセルの置き場となるテーブルと、操作のためのボタン。

<table id="board"></table> <input type="button" id="btnStart" value="start" /> <input type="button" id="btnStop" value="stop" /> <input type="button" id="btnClear" value="clear" />

押されては不味い時に押されないようにボタンの操作可否を設定するようにしたが、まだ何もしてない状態なら押されても問題無いので、初期状態に対しての操作可否設定はしていない。

そしてJavaScript。

処理を大きく二つ、 load イベントと LifeGame クラスとに分けて書いているのは昨日と同じだが、取り扱うイベントが増えたことで前者が倍以上に肥大化してしまった。

addEventListener( "load", () => { const R = 40; const C = 40; const bd = document.getElementById( "board" ); bd.innerHTML = ( "<tr>" + ( "<td class='dead'></td>" ).repeat( C ) + "</tr>" ).repeat( R ); let sts; const dragDrow = ( ev ) => ev.target.className = sts; const dragStop = () => bd.removeEventListener( "mouseover", dragDrow, false ); bd.addEventListener( "mousedown", ( ev ) => { sts = ev.target.className === "alive" ? "dead" : "alive"; ev.target.className = sts; bd.addEventListener( "mouseover", dragDrow, false ); }, false ); bd.addEventListener( "mouseup", dragStop, false ); bd.addEventListener( "mouseout", ( ev ) => ev.target.nodeName === "TABLE" && dragStop(), false ); const btnStart = document.getElementById( "btnStart" ); const btnStop = document.getElementById( "btnStop" ); const btnClear = document.getElementById( "btnClear" ); const setBtnStarted = ( started ) => { btnStart.disabled = started; btnStop.disabled = !started; btnClear.disabled = started; }; const lg = new LifeGame( Array.from( bd.querySelectorAll( "td" ) ), R, C ); let tmr; const stopGame = () => { clearInterval( tmr ); setBtnStarted( false ); }; btnStart.addEventListener( "click", () => { tmr = setInterval( () => !lg.update() && stopGame(), 100 ); setBtnStarted( true ); }, false ); btnStop.addEventListener( "click", stopGame, false ); btnClear.addEventListener( "click", () => lg.clear(), false ); }, false );

load イベント処理では以下を行っている。

  1. 盤面となるテーブルを作る。

  2. 盤面上でマウスボタンを押した時(mousedown)のイベントリスナーを設定する。

    このイベントリスナーで、ボタンを押した位置のセルの生死を反転し、ドラッグで反転結果を他のセルにも適用するように mouseover のイベントリスナーを設定する。

  3. 盤面上でマウスボタンを離した時(mouseup)のイベントリスナーを設定する。

    このイベントリスナーで mouseover のイベントリスナーを解除する。

  4. 盤面からマウスが外に出た時(mouseout)のイベントリスナーを設定する。

    このイベントリスナーでは、イベントが TABLE 要素から発火した場合のみ mouseover のイベントリスナーを解除する。

    テーブルの枠が太いのは、マウスを盤面から外に動かした時に確実にテーブルから mouseout イベントを発火させるため。

  5. LifeGame オブジェクトを生成する。

  6. start ボタンをクリックした時のイベントリスナーを設定する。

    このイベントリスナーで、タイマーによる盤面の定周期更新を開始し、各ボタンの操作可否を設定する。 更新周期は100ms。

    盤面の更新処理は変化があったセルの数を返す。 この値が0、つまり盤面に変化がなくなったら、更新停止処理を呼び出す。

  7. stop ボタンをクリックした時のイベントリスナーを設定する。

    このイベントリスナーで更新停止処理を実行する。

    更新停止処理では、タイマーを停止し、各ボタンの操作可否を反転する。

  8. clear ボタンをクリックした時のイベントリスナーを設定する。

    このイベントリスナーで、盤面の全てのセルを死んだ状態にする。

class LifeGame { constructor( cells, R, C ) { this.R = R; this.C = C; this.cells = cells.map( ( cell, idx, lst ) => ( { myCell : cell, around : this._getAroundCells( idx, lst ), } ) ); } _wrap( i, size ) { return ( i + size ) % size; } _toIdx( r, c ) { return this._wrap( r, this.R ) * this.C + this._wrap( c, this.C ); } _toRC( idx ) { return [ Math.floor( idx / this.C ), idx % this.C ]; } _getAroundCells( idx, lst ) { const [ ir, ic ] = this._toRC( idx ); const rng = [ -1, 0, 1 ]; return rng.map( r => rng.map( c => this._toIdx( ir + r, ic + c ) ) ).flat() .filter( i => i != idx ) // 自分を除外 .map( i => lst[i] ); } update() { return this.cells.map( cell => ( { isAlive : cell.myCell.className === "alive", around : cell.around.filter( i => i.className === "alive" ).length, myCell : cell.myCell, } ) ).map( cell => { if ( cell.isAlive && ( cell.around <= 1 || 4 <= cell.around ) ) { cell.myCell.className = "dead"; return 1; } else if ( !cell.isAlive && cell.around === 3 ) { cell.myCell.className = "alive"; return 1; } return 0; } ).reduce( ( v0, v1 ) => v0 + v1 ); } clear() { this.cells.forEach( cell => cell.myCell.className = "dead" ); } }

こちらの昨日からの変化は update メソッドのみ。

昨日は

  1. { 自分自身, 周囲のセルのリスト } を要素とするリスト
  2. { 自分自身の生死, 周囲の生きているセルの数 } を要素とするリスト
  3. 各要素にルールを適用して、変化がある場合は生死を反映。

としていたのを、今日は

  1. { 自分自身, 周囲のセルのリスト } を要素とするリスト
  2. { 自分自身の生死, 周囲の生きているセルの数 } を要素とするリスト
  3. 各要素にルールを適用して、変化がある場合は生死を反映し、 [ 反映した場合は1、反映しなかった場合は0 ] のリスト
  4. リストの各要素を集計。

として、集計結果をメソッドの戻り値として返している。

このメソッドの中で使っている小道具が map や reduce でいかにも関数志向っぽい雰囲気だが、やっていることはセルの状態変化という副作用で、さらにそこから副作用があったかどうかのリストに変換してるんだよな。 我ながらカオス。

明日は、パターンを外部から設定できるようにする。 使い勝手が良くなったとはいえ、ドット絵をきっちり書くのはやっぱり面倒だからね。