GODRICH
#cssアニメーション#ボタン#ローディング#トランジション#スクロール#ダッシュボード#実用ui#デザイントレンド

ライフゲーム9種 — キャンバスで世代を計算

ライフゲームのルールを計算だけで動かす9種のキャンバス実装です。格子は勝手に動いているように見えるが、実際には毎世代、生存隣接数という一つの値が生死を分け、その値が積み重なった年齢の分だけ色が薄れ、同じ値が配線を伝って信号のように流れる — 九つの計算型セルオートマトンをその値で見る。

自動再生 · タイルを押すとその項目へ · すべて1つのzip

01は他の8種が土台にする、あるいはそこから外れていく基本ルールをそのまま動かします。02は同じ考え方を一行の帯に落とし込み、03は小さな固まりを一箇所に固定して繰り返し発射する装置として使います。04と05はルールの結果を年齢の値やポインタ座標の値に置き換え、06と09はルール表そのものを実行中に、あるいは領域ごとに入れ替えます。07と08はそもそも何を隣接と数えるかを変えていて、四角い8方向の代わりに六角形の6方向を数えたり、二値ではなく四段階の状態を数えたりします。

隣接数の折り返し処理が壊れる、6つの格子に共通する落とし穴

02の帯オートマトンは端を意図的に折り返していて、(x - 1 + COLS) % COLS と (x + 1) % COLS で左右の端の隣が反対側の端から取られるため、ルール30の模様が片側からはみ出しても反対側に現れ続けます。同じ剰余演算を01・03・04・05・06・08に持ち込むと壊れます — この6種は代わりに境界チェック(if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue)を使っていて、端のセルは単純に数える隣接が減るだけで、グライダーや信号ヘッドが端に達しても折り返さずそこで止まるか消えます。もう一つの落とし穴はGEN_INTERVAL_MSにあります — どの実装もrequestAnimationFrameの呼び出し回数ではなく蓄積時間(acc += dt; while (acc >= GEN_INTERVAL_MS))で世代を進めるので、144Hzのディスプレイで60Hzの2.4倍速く世代が進んでしまうことはありません。

ライフゲームとは何か — 隣接数という一つの値が全画面を作るしくみ

01・03・04・05・06・08のstep()を読むと形は共通しています。各セルの周囲の生きた隣接数を数え、すでに生きているセルなら生存範囲と、死んでいるセルなら誕生数一つと比較して次の状態を決め、別バッファに書き込んでから入れ替える、という流れです。02も隣接数を数えて比較するという同じ形をたどりますが、輪を一次元の3セルに狭め、生存/誕生の比較を8ビットの参照テーブルに置き換えています。07は3x3の輪の考え方を保ったまま、8マスではなく六角形の6マスに数え直しています。ローディング アニメーション js 9パターン — 物理演算で作るの9番目のパターンも同じ生存/誕生ルールを動かしていますが、それは毎世代「点灯セルの割合」という一つの数値を計算するためだけで、その割合が進行度の目標に届いた瞬間に世代を止めます。このページの9種はどれも止まりません — 世代を重ねること自体が目的で、目指す割合を数えているわけではないからです。

移動面積・強度・フレーム数で比べる9種のライフゲームパターン

どのパターンも自前の蓄積時間間隔で格子全体を再計算し、生きたセルをfillRectか六角パスで塗り直すので、以下の面積と強度は480x300のキャンバスのうちどれだけ実際に塗り替わったかを表していて、JavaScriptの実行速度ではありません。

パターン 累積移動面積 平均強度 移動フレーム
01 隣接数による生死判定 51.9% 144.5 23/23
02 一次元ルール帯 53.9% 47.8 23/23
03 グライダー銃シーン 27.6% 47.6 23/23
04 年齢による色減衰 55.7% 32.8 23/23
05 ポインタ描画シード 17.0% 146.7 23/23
06 ルール実験ラボ 60.3% 55.1 23/23
07 六角格子オートマトン 49.5% 55.0 23/23
08 ワイヤーワールド回路 26.7% 136.8 23/23
09 ヒーローシーン 62.4% 148.5 23/23

01生存隣接数の値が生死を分ける格子オートマトン

各セルは8方向の生きた隣接数を数え、すでに生きていればSURVIVE_MIN/SURVIVE_MAXと、死んでいればBIRTH_COUNTと比較し、48x30の格子全体が別バッファに再計算されてから入れ替わります — 面積を広く均等に塗り替えるので、9種の中でも平均面積が高いタイプの代表です。

生存隣接数の値で生死が決まる基本格子オートマトン
新しいタブで開く

01 · 生存隣接数の値で生死が決まる基本格子オートマトン — 計測 累積面積 51.899% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

SURVIVE_MINBIRTH_COUNTfillRect
  function step(){
    for (let y = 0; y < ROWS; y++){
      for (let x = 0; x < COLS; x++){
        const alive = grid[idx(x, y)] === 1;
        const n = neighborCount(x, y);
        let willLive = 0;
        if (alive){
          willLive = (n >= SURVIVE_MIN && n <= SURVIVE_MAX) ? 1 : 0;
        } else {
          willLive = (n === BIRTH_COUNT) ? 1 : 0;
        }
        next[idx(x, y)] = willLive;
      }
    }
    const tmp = grid; grid = next; next = tmp;
    generation++;
  }

02ルール番号の値一つで次の行の模様が決まる帯オートマトン

RULEは左・自分・右の組み合わせごとに1ビットずつ、8ビットの参照テーブルに一度だけ展開されます。毎世代copyWithinでバッファ全体を一行上へずらしてから、最下行をそのテーブルで計算し直します。帯オートマトンには上下方向の隣接がないため、一行は必ずその一つ上の行だけに依存します。

1行の規則番号の値が次の行の模様を決めるストライプ・オートマトン
新しいタブで開く

02 · 1行の規則番号の値が次の行の模様を決めるストライプ・オートマトン — 計測 累積面積 53.91% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RULEcopyWithinfillRect
  let grid = new Uint8Array(COLS * ROWS);

  function idx(x, y){ return y * COLS + x; }

  function clearGrid(){
    grid.fill(0);
  }

  function seedRow(seedX){
    grid[idx(seedX, ROWS - 1)] = 1;
  }

  function step(){
    grid.copyWithin(0, COLS, ROWS * COLS);
    const base = (ROWS - 2) * COLS;
    const outBase = (ROWS - 1) * COLS;
    for (let x = 0; x < COLS; x++){
      const l = grid[base + ((x - 1 + COLS) % COLS)];
      const m = grid[base + x];
      const r = grid[base + ((x + 1) % COLS)];
      const pattern = l * 4 + m * 2 + r;
      grid[outBase + x] = ruleBits[pattern];
    }
  }

03固定発射装置が一定間隔で移動体を放つシーンオートマトン

GUN_ROWSは36セルのライフゲーム グライダー銃の固まりを、左右反転させた2組ずつ固定行に配置します。この固まり自身のセルも他と同じ生存/誕生ルールに従うため、毎サイクル自分で再生してグライダーを一つずつ押し出し続けます。放たれたグライダーはキャンバスを出るまで形を保ちます — 9種の中で平均面積が最も低い(27.6%)のは、発射装置と漂うグライダーの間の大部分が空いたままだからです。

固定した発射パターンが一定間隔で移動体を放つシーン・オートマトン
新しいタブで開く

03 · 固定した発射パターンが一定間隔で移動体を放つシーン・オートマトン — 計測 累積面積 27.599% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

GUN_ROWSplaceGunseedGrid
  function placeGun(ox, oy, mirror){
    for (let i = 0; i < GUN_CELLS.length; i++){
      const dx = GUN_CELLS[i][0], dy = GUN_CELLS[i][1];
      const x = ox + (mirror ? (GUN_W - 1 - dx) : dx);
      const y = oy + dy;
      if (x >= 0 && x < COLS && y >= 0 && y < ROWS) grid[idx(x, y)] = 1;
    }
  }

  const GUN_ROWS = [4, 18, 32];

  function seedGrid(offset){
    grid.fill(0);
    for (let i = 0; i < GUN_ROWS.length; i++){
      placeGun(2 + offset, GUN_ROWS[i], false);
      placeGun(COLS - 2 - (GUN_W - 1) - offset, GUN_ROWS[i], true);
    }
  }

04世代年齢の値で色が薄れていくオートマトン

ageという別バッファが、セルが生きている間はAGE_CAPまで上がり、死ぬとTRAIL_DECAYずつ下がります。描画側はその年齢の値を読んで低い色から高い色へのグラデーションに変換するので、alive自体はもう0に切り替わっていても、死んだ直後のセルはしばらく薄れながら塗られ続けます。

世代の年齢値の分だけ色が薄れる残像オートマトン
新しいタブで開く

04 · 世代の年齢値の分だけ色が薄れる残像オートマトン — 計測 累積面積 55.72% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

TRAIL_DECAYAGE_CAPTRAIL_HIGH
  function step(){
    for (let y = 0; y < ROWS; y++){
      for (let x = 0; x < COLS; x++){
        const i = idx(x, y);
        const wasAlive = alive[i] === 1;
        const n = neighborCount(x, y);
        let willLive = 0;
        if (wasAlive){
          willLive = (n >= SURVIVE_MIN && n <= SURVIVE_MAX) ? 1 : 0;
        } else {
          willLive = (n === BIRTH_COUNT) ? 1 : 0;
        }
        nextAlive[i] = willLive;
      }
    }
    for (let i = 0; i < alive.length; i++){
      if (nextAlive[i]){
        age[i] = Math.min(AGE_CAP, age[i] + 1);
      } else {
        age[i] = Math.max(0, age[i] - TRAIL_DECAY);
      }
    }
    const tmp = alive; alive = nextAlive; nextAlive = tmp;
  }

05ポインタ座標の値が直接セルを切り替える描画オートマトン

ポインタがボタンを押したまま動くたびに、そのクライアント座標が格子のセルに変換され、そのセルを強制的に生きた状態にして即座に描画します。ポインタが押されている間は世代の進行そのものが止まるので、描いた形はボタンを離すまで画面に静止し、離した瞬間に他のパターンと同じ生存/誕生のstep()へ制御が戻ります。

カーソル座標値でマスを直接オン・オフする描画オートマトン
新しいタブで開く

05 · カーソル座標値でマスを直接オン・オフする描画オートマトン — 計測 累積面積 16.988% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

autoSeedpointermoveSURVIVE_MIN
  function onPointerDown(e){
    human();
    isDrawing = true;
    paused = true;
    updateAria();
    paintAt(e);
  }

  function onPointerMove(e){
    if (!isDrawing) return;
    paintAt(e);
  }

  function onPointerUp(){
    if (!isDrawing) return;
    isDrawing = false;
    paused = false;
    updateAria();
    render();
  }

06ルール表の値を丸ごと入れ替えるルール実験ラボ

RULESETSには完全な生存/誕生テーブルが4組入っていて、CYCLE_GENERATIONS世代ごとにテーブル全体が次のものへ入れ替わります。そのたびにseedSame()が保存しておいたスナップショットから開始配置をそのまま復元するので、同じ初期配置が4つの異なるルール表を順番に通り抜け、4回目には元とはまったく違う見た目になります。

規則表の値を切り替えると模様が変わる規則実験オートマトン
新しいタブで開く

06 · 規則表の値を切り替えると模様が変わる規則実験オートマトン — 計測 累積面積 60.251% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RULESETSCYCLE_GENERATIONSbaseSeedSnapshot
  function step(){
    const rule = RULESETS[ruleIndex];
    for (let y = 0; y < ROWS; y++){
      for (let x = 0; x < COLS; x++){
        const alive = grid[idx(x, y)] === 1;
        const n = neighborCount(x, y);
        const willLive = alive ? rule.survive.indexOf(n) !== -1 : rule.born.indexOf(n) !== -1;
        next[idx(x, y)] = willLive ? 1 : 0;
      }
    }
    const tmp = grid; grid = next; next = tmp;
    genCount++;
    if (genCount >= CYCLE_GENERATIONS){
      genCount = 0;
      ruleIndex = (ruleIndex + 1) % RULESETS.length;
      seedSame();
    }
  }

07六方向の値で生死が分かれる蜂の巣格子オートマトン

このパターンだけ、どのセルも隣接は常に8ではなく6です。ただしその6方向は自分の列が偶数か奇数かで変わるため、OFF_EVENとOFF_ODDという二つの異なるオフセット表を用意していて、隣接数を数える側は列の偶奇を一回判定するだけでどちらを使うか選びます。四角格子の8方向ルールでは届かない方向にも、模様が蜂の巣を伝って広がっていきます。

六角形の隣接数値で分かれるハニカム格子オートマトン
新しいタブで開く

07 · 六角形の隣接数値で分かれるハニカム格子オートマトン — 計測 累積面積 49.486% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

OFF_EVENOFF_ODDhexPath
  // odd-q オフセット六角座標系の隣接6マス — 列が偶数か奇数かで表が変わる。
  const OFF_EVEN = [[-1,-1],[-1,0],[0,-1],[0,1],[1,-1],[1,0]];
  const OFF_ODD  = [[-1,0],[-1,1],[0,-1],[0,1],[1,0],[1,1]];

  function seedGrid(){
    for (let i = 0; i < grid.length; i++) grid[i] = rnd() < 0.33 ? 1 : 0;
  }

  function neighborCount(x, y){
    const offs = (x & 1) ? OFF_ODD : OFF_EVEN;
    let n = 0;
    for (let i = 0; i < 6; i++){
      const nx = x + offs[i][0], ny = y + offs[i][1];
      if (nx < 0 || ny < 0 || nx >= COLS || ny >= ROWS) continue;
      n += grid[idx(nx, ny)];
    }
    return n;
  }

08四段階の値が配線を伝う信号回路オートマトン

このパターンでは各セルが「空・配線・信号ヘッド・信号テール」の四つの値のうちどれか一つを持ちます。配線セルは、生きた信号ヘッドの隣接数がHEAD_NEIGHBORS_MINからHEAD_NEIGHBORS_MAXの範囲に収まったときだけヘッドに切り替わり、ヘッドは次の世代で必ずテールに、テールは必ず配線に戻ります。そのため信号はあらかじめ組んだジグザグの配線に沿って一方向にしか進めません。

4段階の値が配線を伝う信号回路オートマトン
新しいタブで開く

08 · 4段階の値が配線を伝う信号回路オートマトン — 計測 累積面積 26.672% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

HEAD_NEIGHBORS_MAXbuildCircuitneighborHeadCount
  function step(){
    for (let y = 0; y < ROWS; y++){
      for (let x = 0; x < COLS; x++){
        const i = idx(x, y);
        const s = grid[i];
        let ns = s;
        if (s === WIRE){
          const hc = neighborHeadCount(x, y);
          if (hc >= HEAD_NEIGHBORS_MIN && hc <= HEAD_NEIGHBORS_MAX) ns = HEAD;
        } else if (s === HEAD){
          ns = TAIL;
        } else if (s === TAIL){
          ns = WIRE;
        }
        next[i] = ns;
      }
    }
    const tmp = grid; grid = next; next = tmp;
  }

09複数のルールの値が一画面で同時に回るヒーローシーンオートマトン

QUADSがキャンバスを四つの領域に分け、RULESETSが領域ごとに専用の生存/誕生テーブルを割り当てます。隣接数を数える側はその領域自身の境界だけを探索し、別の領域の隣接が境界を越えて漏れ込むことはありません。一枚のキャンバスの上で4つの異なるルール表が同時に回っているため、9種の中で平均面積・平均強度がどちらも最も高くなっています。

複数の規則値が同一画面で同時に回るヒーローシーン・オートマトン
新しいタブで開く

09 · 複数の規則値が同一画面で同時に回るヒーローシーン・オートマトン — 計測 累積面積 62.378% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る

RULESETSQUADSneighborCountInQuad
  function step(){
    for (let qi = 0; qi < 4; qi++){
      const q = QUADS[qi];
      const rule = RULESETS[qi];
      for (let y = q.y0; y <= q.y1; y++){
        for (let x = q.x0; x <= q.x1; x++){
          const alive = grid[idx(x, y)] === 1;
          const n = neighborCountInQuad(x, y, q);
          const willLive = alive ? rule.survive.indexOf(n) !== -1 : rule.born.indexOf(n) !== -1;
          next[idx(x, y)] = willLive ? 1 : 0;
        }
      }
    }
    const tmp = grid; grid = next; next = tmp;
  }

生存の下限・減衰速度・信号の範囲を動かすと何が変わるか

以下の3種はどれも定数を一つだけ変えていて、格子もシード配置もイベント処理もそのままです。見た目の違いはすべてその一つの数値から来ています。

変形 変えた値 感触
生存隣接数の下限の値を調整 01 SURVIVE_MIN 2 → 1 生存の下限を2から1に下げると、隣接が一つでも生きていればセルが生き続けるため、基本パターンよりはるかに長くセルが残り、格子全体が薄まらずに密なまま固まりやすくなります。
年齢による色減衰速度の値を調整 04 TRAIL_DECAY 1 → 2 減衰の値を1から2に上げると、死んだセルの年齢の値が毎世代半分の速さで下がるため、基本パターンのおよそ半分の長さで軌跡が消えます。
信号ヘッドの隣接数許容範囲の値を調整 08 HEAD_NEIGHBORS_MAX 2 → 1 許容上限を2から1に狭めると、隣接ヘッドがちょうど1つのときしか配線がヘッドに切り替わらなくなり、二本の信号が合流する分岐点で条件を満たせず、基本パターンより信号が途切れやすくなります。

prefers-reduced-motionが9種すべてから取り除くもの — アクセシビリティの注記

どのタイルも読み込み時に一度window.matchMedia('(prefers-reduced-motion: reduce)')を確認します — この一致条件はMDNのprefers-reduced-motion解説にあるメディアクエリそのものです。一致した場合、自走するrequestAnimationFrameループはそもそも一度も始まらず、最初のクリック・Enter・スペースキーの押下でちょうど一世代分のstep()を計算し、一度だけ描画します — 格子は確かに一世代進みますが、その後は何もひとりでに動き続けません。どのタイルも実行中と停止中で切り替わるaria-labelを持ち続けるので、タブキーでたどり着いたキーボード利用者(tabindex="0"、role="button")は、キャンバスを見なくても状態を把握できます。イベントは9種のうち8種で共通していて、タイルへのclick・keydown・resizeと、同じkeydown内のRキーによる再シードだけです。05だけが描画モードのためにpointerdown・pointermove・pointerupを追加しています。上のすべてのstep()とSCSS・React版一式は一つのzipにまとまっていて、p9w3yqnrで開きます — このページのどのパターンでも同じパスワードです。

生成背景・回路図・繰り返しループとして実際に使う場面

09の4つのルール表が同時に回る画面は、ヒーローセクションや空状態のイラストの背後に置く生成背景としてそのまま使えます。訪れるたびに違う見た目になりますが、シード配置とルール表が毎回その仕事を肩代わりするので、デザイナーが形を手で配置する必要はありません。08の配線と信号のオートマトンは背景というより説明図に近く、固定された回路の形の上を信号ヘッドが這っていく様子は、静的な矢印やループ動画では伝えにくい「これがこれにつながっている」という関係を、動画ファイルを配信せずに伝えられます。

03のグライダー銃は、生成的な画面よりも小さな繰り返しループを求める場所で役に立ちます。セクションの区切りや、一定間隔でグライダーを外へ放つ角の装飾は、09のヒーローシーンのように毎フレーム画面全体を描き直さなくても、生きているように見えます。

反応拡散の格子・落ち物系の格子との違い

チューリングパターン シミュレーション9選 — ライブラリなし、2物質の反応グリッドだけでは1セルにつき二つの連続した濃度の値を持ち、毎フレーム拡散と反応の式で両方を更新するので、模様は水にインクが広がるように隣のセルとにじみ合います。このページの9種は、どのセルの状態も小さな整数だけです — 0か1、あるいは配線の4状態のうちどれか — 隣接数を数えて固定テーブルと照らし合わせて決まるので、にじみは一切起きません。セルは生きているか死んでいるか、オンかオフか、配線かヘッドかのどちらかで、その間の値は存在しません。

フォーリングサンド(falling sand simulator) 9種の作り方は重力のルールに従って素材を下へ動かし、下や横のセルと入れ替えるので、一粒の位置がフレームごとに変わります。この9種はどれもセルの位置を動かしません — セルのx, yは変わらず、生きていると数えられるかどうかだけが変わり、動いているように見える感覚はどの固定セルが次にオン・オフを切り替えるかだけから生まれています。

html5 canvas アニメーション9種 — 水滴とブラックホールは個々のピクセルを直接読み書きするので、屈折やレンズのような計算が格子セルとセルの間にある値を曲げられます。この9種はピクセルには一切触れず、最小の単位は常にセル一つで、fillRectか六角パスだけがキャンバスに届きます。

このグリッドを自分のプロジェクトで動かす前によくある質問

ライフゲーム javascriptとしてstep()だけをキャンバス抜きで使えますか

はい — 上に載せたどのstep()も、読み書きするのはUint8Arrayのプレーンなバッファだけです。キャンバスのコンテキストに触れるのは描画側だけなので、ルールのロジック自体はキャンバスに依存せず、Node.jsのスクリプトやテストファイル、別のレンダラーにそのまま持ち込めます。

なぜ07だけ隣接を8ではなく6で数えるのですか

07はセルを四角ではなく六角形として配置していて、六角形は隣り合う辺を持つ相手が常に6つしかないからです。そのためオフセット表も四角格子の8マス分ではなく、6マス分だけを用意しています。

03のグライダー銃はいつか発射を止めますか

止まりません — 発射装置自身のセルも格子の他の部分と同じ生存/誕生ルールに従うため、36セルの固まりが毎サイクル自分自身を再生し続け、タイルが世代を進めている限り発射し続けます。

解凍パスワードを入力してください

パスワードはこの記事の本文の中にあります。読み進めると出てきます。