ライフゲームを作るの3

昨日の続き。 今日はパターンを外部から設定できるようにした。 まずはその成果。

パターンを選択して preset ボタンをクリックすると、選択したパターンを盤面に反映する。 start ボタンで開始とかは昨日と同じ。

特徴的な動きを作り出す名前付きのパターンがある。 それらを試してみたいが、パターンを毎回手入力するのが面倒臭い。

そんな動機で追加した機能だが、結局どこかでパターンを入力する必要があるんだよな。 なるべく入力する量が少なくて済むようにと、生きているセルの座標を配列として持たせるようにしたのだが、座標を入力するのはやっぱり面倒だった。 一度作ってしまえば何度でも再生できるので、無駄ではないんだけどさ。

パターンは Wikipedia から拾ってきた。

どのパターンも無限に広い盤面を想定しているが、俺のは盤面の端が逆側の端と繋がっている閉じた世なので、パターンが想定していない自己干渉が発生してしまう。 そのせいで途中からカオスになってしまうものもあるが気にしない。

グライダー銃だけカオス対策している。 こいつは振動と生成がセットになっていて、カオスになってしまうのが惜しい。 そしてカオス対策が、グライダーの飛び先にイーターを設置するだけと簡単。 と、やらない理由がないからね。

以下、ソース一式。

#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; } #patterns label { display: inline-block; width: 10em; }

css定義は昨日とほぼ同じ。

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

HTML要素は、パターン反映のボタンと、パターンの選択肢の置き場を追加している。 選択肢は盤面同様にスクリプトで作成するので、この時点では空。

addEventListener( "load", () => { const R = 40; const C = 50; const bd = document.getElementById( "board" ); bd.innerHTML = ( "<tr>" + ( "<td class='dead'></td>" ).repeat( C ) + "</tr>" ).repeat( R ); const pt = document.getElementById( "patterns" ); pt.innerHTML = Object.keys(PATTERNS).reduce( ( acc, key ) => acc + `<label><input type="radio" name="pattern" value="${key}" /> ${PATTERNS[key].label}</label> `, "" ); 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 btnPreset = document.getElementById( "btnPreset" ); const setBtnStarted = ( started ) => { btnStart.disabled = started; btnStop.disabled = !started; btnClear.disabled = started; btnPreset.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 ); btnPreset.addEventListener( "click", () => { const ptn = Array.from( document.querySelectorAll( "#patterns input[name='pattern']" ) ).find( ( elm ) => elm.checked ); if ( ptn ) { lg.clear(); lg.setPattern( PATTERNS[ ptn.value ]); } }, false ); }, false );

load 時のイベント処理。 選択肢の作成と、選択したパターンを反映するための処理を追加している。

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" ); } setPattern( ptn ) { ptn.pattern.map( ( pos ) => this._toIdx( pos[0] + ptn.offset[0], pos[1] + ptn.offset[1] ) ) .forEach( ( idx ) => this.cells[ idx ].myCell.className = "alive" ); } }

LifeGame クラスも同じで、パターンを反映するためのメソッド setPattern を追加。

const PATTERNS = { glider : { label : "グライダー", offset : [15,25], pattern : [ [ 0, 0], [ 1, 0], [ 1, 2], [ 2, 0],[ 2, 1], ] }, lightSpaceship : { label : "軽量級宇宙船", offset : [15,25], pattern : [ [ 0, 1], [ 0, 4], [ 1, 0], [ 2, 0], [ 2, 4], [ 3, 0],[ 3, 1],[ 3, 2],[ 3, 3], ] }, middleSpaceship : { label : "中量級宇宙船", offset : [15,25], pattern : [ [ 0, 3], [ 1, 1], [ 1, 5], [ 2, 0], [ 3, 0], [ 3, 5], [ 4, 0],[ 4, 1],[ 4, 2],[ 4, 3],[ 4, 4], ] }, heavySpaceship : { label : "重量級宇宙船", offset : [15,25], pattern : [ [ 0, 3],[ 0, 4], [ 1, 1], [ 1, 6], [ 2, 0], [ 3, 0], [ 3, 6], [ 4, 0],[ 4, 1],[ 4, 2],[ 4, 3],[ 4, 4],[ 4, 5,], ] }, gliderGun : { label : "グライダー銃", offset : [ 3, 1], pattern : [ [ 0,24], [ 1,22], [ 1,24], [ 2,12],[ 2,13], [ 2,20],[ 2,21], [ 2,34],[ 2,35], [ 3,11], [ 3,15], [ 3,20],[ 3,21], [ 3,34],[ 3,35], [ 4, 0],[ 4, 1], [ 4,10], [ 4,16], [ 4,20],[ 4,21], [ 5, 0],[ 5, 1], [ 5,10], [ 5,14], [ 5,16],[5,17], [ 5,22], [ 5,24], [ 6,10], [ 6,16], [ 6,24], [ 7,11], [ 7,15], [ 8,12],[ 8,13], [30,44],[30,45], [31,44], [31,46], [32,46], [33,46],[33,47], ] }, train : { label : "シュポシュポ列車", offset : [10,15], pattern : [ [ 0, 3], [ 1, 4], [ 2, 0], [ 2, 4], [ 3, 1],[ 3, 2],[ 3, 3],[ 3, 4], [ 7, 0], [ 8, 1],[ 8, 2], [ 9, 2], [10, 2], [11, 1], [14, 3], [15, 4], [16, 0], [16, 4], [17, 1],[17, 2],[17, 3],[17, 4], ] }, breeder1 : { label : "ブリーダー1", offset : [15,20], pattern : [ [ 0, 6], [ 1, 4], [ 1, 6],[ 1, 7], [ 2, 4], [ 2, 6], [ 3, 4], [ 4, 2], [ 5, 0], [ 5, 2], ] }, breeder2 : { label : "ブリーダー2", offset : [15,20], pattern : [ [ 0, 0],[ 0, 1],[ 0, 2], [ 0, 4], [ 1, 0], [ 2, 3],[ 2, 4], [ 3, 1],[ 3, 2], [ 3, 4], [ 4, 0], [ 4, 2], [ 4, 4], ] }, dieHard : { label : "ダイハード", offset : [15,20], pattern : [ [ 0, 6], [ 1, 0],[ 1, 1], [ 2, 1], [ 2, 5],[ 2, 6],[ 2, 7], ] }, acorn : { label : "団栗", offset : [15,20], pattern : [ [ 0, 1], [ 1, 3], [ 2, 0],[ 2, 1], [ 2, 4],[ 2, 5],[ 2, 6], ] }, galaxy : { label : "銀河", offset : [15,20], pattern : [ [ 0, 0],[ 0, 1],[ 0, 2],[ 0, 3],[ 0, 4],[ 0, 5], [ 0, 7],[ 0, 8], [ 1, 0],[ 1, 1],[ 1, 2],[ 1, 3],[ 1, 4],[ 1, 5], [ 1, 7],[ 1, 8], [ 2, 7],[ 2, 8], [ 3, 0],[ 3, 1], [ 3, 7],[ 3, 8], [ 4, 0],[ 4, 1], [ 4, 7],[ 4, 8], [ 5, 0],[ 5, 1], [ 5, 7],[ 5, 8], [ 6, 0],[ 6, 1], [ 7, 0],[ 7, 1], [ 7, 3],[ 7, 4],[ 7, 5],[ 7, 6],[ 7, 7],[ 7, 8], [ 8, 0],[ 8, 1], [ 8, 3],[ 8, 4],[ 8, 5],[ 8, 6],[ 8, 7],[ 8, 8], ] }, pulser : { label : "パルサー", offset : [18,22], pattern : [ [ 0, 0], [ 0, 4], [ 1, 0], [ 1, 2], [ 1, 4], [ 2, 0], [ 2, 4], ] }, };

パターン定義。 見れば判ることだが、初期配置で生かすセルの座標の配列として定義している。

盤面の大きさに対してある程度の汎用性を持たせるために、座標は、生きているセルの一式を囲む最小矩形領域の左上を原点としている。 この一式を、盤面自体の原点からオフセットで指定さするだけずらして配置する。

座標と座標の間に隙間が空いているのは、Wikipediaに掲載されていたパターンの画像との対応を取りやすくするため。 処理的には、セルの各座標に隙間を開ける必要は無い。

ライフゲームが考案された頃はまだPCなんて無くて、研究は主に方眼紙と鉛筆で行われていたそうだ。 いろんなパターンの発見には、きっと大変な苦労があったのだろう。 それができるから研究者なんだろうが、凄いよな、いろんな意味で。 俺は発見されたパターンの入力だけで気力が尽きたからね。

ということで、ライフゲームを作るのはここで一旦終了。 いつか時間ができたら、WebGLで3Dのライフゲームも作ってみたい。