休憩時間をライフゲームに委ねるアプリをつくってみた

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/5b08b0da-074a-8ebc-a08a-1fda28b8fb5a.png)

## こんなものをつくります

![無題の動画 (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/eae1caea-b67d-ca24-a7f4-3adecc9c2403.gif)


**デスクトップで動く休憩時間管理アプリです。**
開始してから黒いセルがなくなるまで休憩できる！というルールになっています。

- 休憩中
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/d997e080-52cb-fd37-cfd8-18c06f0f5f22.png)

- 休憩終了
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/3562256a-1342-8af8-cfc0-45649e64c3b4.png)


これは**Game of Life(ライフゲーム)**という数理モデルのシミュレーションです。黒いセルが生命の誕生から淘汰の過程を模していると言われています。ここでは、この黒いセルを残す間は休憩時間を取ることができる、というデスクトップアプリにしました。

実装方法はさておき、まず体験してみたいという方は[こちら](https://blackgameoflife.netlify.app/)からどうぞ！

## Electronでデスクトップアプリをつくる
この記事では実装面として**Electron**について主に解説します。
**Game of Life**については後半に補足しておきます。

### Electronの基礎
**Electron**は、JavaScript、HTML、CSS によるデスクトップアプリケーションを構築するフレームワークです。**GitHub**が開発・管理をしており、**Chrommium**と**Node.js**で構成されています。

https://www.electronjs.org/

Electronで開発された代表的なサービスの一例です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/944672b2-1306-cfaf-eacc-7c026cf56080.png)

## 実装
ステップは次の通りです。

:::note info
1. Node.jsのインストール
1. Electronのインストール
1. スクリプトの準備
1. プロジェクトの実行
:::

また、最終的なプロジェクトのディレクトリ構成は次のようになります。
```bash:bash
sample-app.
│  index.html
│  main.js
│  script.js
│  style.css
│  package.json
│  preload.js
│
└─node_modules
```

### 1.Node.jsのインストール
以下のコードでnodeとnpmのバージョンが表示されたらOKです。
```bash:bash
node -v
#v20.17.0
npm -v
#10.8.2
```
もしNode.jsがなければ[公式ページ](https://nodejs.org/en/)よりインストールしてください。インストール後はバージョンが表示されるか再度確認することをおすすめします。

https://nodejs.org/en/

### 2.Electronのインストール
新しいプロジェクトフォルダを作成し、npmプロジェクトを初期化します。

```bash:bash
mkdir sample-app
cd sample-app
npm init -y
```

なお、`nmp`はパッケージ管理ツールで、`init`で初期化することで、`package.json`が作成されます。

続いて、同じディレクトリでElectronをインストールします。
```bash:bash
npm install --save-dev electron
```

### 3.スクリプトの準備

#### ①main.js
まず、プロジェクトのルートディレクトリに`main.js`ファイルを作成します。
```javascript:main.js
const { app, BrowserWindow } = require('electron');
const path = require('path');

function createWindow() {
  const mainWindow = new BrowserWindow({
    width: 800,
    height: 600,
    webPreferences: {
      preload: path.join(__dirname, 'preload.js'),
      nodeIntegration: true,
      contextIsolation: false
    }
  });

  mainWindow.loadFile('index.html');
}

app.whenReady().then(() => {
  createWindow();

  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) {
      createWindow();
    }
  });
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') {
    app.quit();
  }
});
```
Electronが初めに読み込むファイル (エントリーポイント) が`main.js`となります。

#### ②package.json
また、`package.json`もメインを`index.js`から`main.js`に変更する必要があります。

```json:package.json
{
  "name": "sample-app",
  "version": "1.0.0",
  "description": "",
  "main": "main.js",
  "scripts": {
    "start": "electron ."
  },
  "devDependencies": {
    "electron": "^latest"
  }
}
```
Electronのインストールが上手くいっていれば、`devDependencies`内に記述されます。

#### ③index.html
:::note warn
ここからはwebアプリをつくるのと同様の作法でHTML/CSS/JavaScriptのファイルをつくればOKです！
:::
グリッドとボタンを準備しておきます。

```html:index.html
<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Game of Life</title>
  <link rel="stylesheet" href="style.css">
</head>
<body>
  <h1>Game of Life</h1>
  <p id="message">生命(黒いセル)が残っている間あなたは休憩できます</p>
  <div id="grid"></div>
  <button id="startButton">休憩開始</button>
  <button id="resetButton">もう一回</button>

  <script src="script.js"></script>
</body>
</html>
```

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/6de93353-ec31-442a-026f-60fe7dc88e70.png)

#### ④style.css
重要なのは下から二番目の`.cell.alive`です。JavaScriptで`alive`クラスをon/offすることでセルの塗りつぶしを制御します。

```css:style.css
body {
  display: flex;
  flex-direction: column;
  align-items: center;
  font-family: Arial, sans-serif;
}

h1 {
  margin-bottom: 20px;
}

#grid {
  display: grid;
  grid-template-columns: repeat(50, 10px);
  grid-template-rows: repeat(50, 10px);
  gap: 1px;
}

.cell {
  width: 10px;
  height: 10px;
  background-color: lightgray;
  border: 1px solid #ddd;
}

.cell.alive {
  background-color: black;
}

button {
  margin: 10px;
  padding: 10px;
}
```

#### ④script.js
最後にJavaScriptのコードですが、こちらは長いので折りたたんでおきます。簡単にすると次のルールで進行させています。

:::note info
1. 初めに6マスの集まり(生命)を黒く塗りつぶす
1. さらにセルをクリックすれば追加で好きな数を塗りつぶすことができる
1. 「休憩開始」を押すと生命のスタート ※規則はGame of Lifeの説明で補足します
1. 動きのない生命は5ターンを経過すると消滅する
1. すべてのセルが白くなったら(生命消滅)、「休憩終了」と表示される
1. 「もう一度」ボタンを押すと1に戻る
:::

<details><summary>ソースコード</summary>

```javascript:script.js
const gridSize = 50;
let grid = [];
let nextGrid = [];
let stabilityCounter = [];
let intervalId;

const gridElement = document.getElementById('grid');
const startButton = document.getElementById('startButton');
const resetButton = document.getElementById('resetButton');
const messageElement = document.getElementById('message');

// グリッドを初期化し、ランダムに6つのセルを生存状態にする
function createGrid() {
  grid = [];
  nextGrid = [];
  stabilityCounter = [];
  gridElement.innerHTML = '';

  for (let row = 0; row < gridSize; row++) {
    const gridRow = [];
    const nextGridRow = [];
    const stabilityRow = [];
    for (let col = 0; col < gridSize; col++) {
      const cell = document.createElement('div');
      cell.classList.add('cell');
      cell.addEventListener('click', () => toggleCellState(row, col));
      gridElement.appendChild(cell);
      gridRow.push(0);
      nextGridRow.push(0);
      stabilityRow.push(0); // 安定性カウンタを初期化
    }
    grid.push(gridRow);
    nextGrid.push(nextGridRow);
    stabilityCounter.push(stabilityRow); // 安定性カウンタをグリッドに追加
  }

  // ランダムに中心の1つのセルとその周囲を選ぶ
  let centerRow = Math.floor(Math.random() * (gridSize - 2)) + 1; // 中心セルの行 (1～48の範囲)
  let centerCol = Math.floor(Math.random() * (gridSize - 2)) + 1; // 中心セルの列 (1～48の範囲)

  // 中心セルを生存状態にする
  grid[centerRow][centerCol] = 1;
  updateCell(centerRow, centerCol);

  // 周囲の8マスの中からランダムに5つ選ぶ
  const neighborOffsets = [
    [-1, -1], [-1, 0], [-1, 1],
    [0, -1],         [0, 1],
    [1, -1], [1, 0], [1, 1]
  ];

  // シャッフルして最初の5つを選択
  shuffleArray(neighborOffsets);
  for (let i = 0; i < 5; i++) {
    let [rowOffset, colOffset] = neighborOffsets[i];
    let newRow = centerRow + rowOffset;
    let newCol = centerCol + colOffset;
    grid[newRow][newCol] = 1; // 周囲セルを生存状態にする
    updateCell(newRow, newCol);
  }

  // メッセージをリセット
  messageElement.textContent = "生命(黒いセル)が残っている間あなたは休憩できます";
}

// 配列をシャッフルする関数（Fisher–Yatesアルゴリズム）
function shuffleArray(array) {
  for (let i = array.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [array[i], array[j]] = [array[j], array[i]];
  }
}

// セルの状態を切り替える
function toggleCellState(row, col) {
  grid[row][col] = grid[row][col] === 1 ? 0 : 1;
  updateCell(row, col);
}

// グリッドの描画を更新
function updateCell(row, col) {
  const cell = gridElement.children[row * gridSize + col];
  cell.classList.toggle('alive', grid[row][col] === 1);
}

// 次の世代の状態を計算
function calculateNextGeneration() {
  let aliveCount = 0;
  for (let row = 0; row < gridSize; row++) {
    for (let col = 0; col < gridSize; col++) {
      const aliveNeighbors = countAliveNeighbors(row, col);

      // セルの新しい状態を計算
      let newState = grid[row][col];
      if (grid[row][col] === 1) {
        // 生存ルール
        newState = aliveNeighbors === 2 || aliveNeighbors === 3 ? 1 : 0;
      } else {
        // 誕生ルール
        newState = aliveNeighbors === 3 ? 1 : 0;
      }

      // 状態が変わらない場合、安定性カウンタを増やす
      if (newState === grid[row][col]) {
        stabilityCounter[row][col]++;
      } else {
        stabilityCounter[row][col] = 0; // 状態が変わればカウンタをリセット
      }

      // 5ターン以上変化がないセルを強制的にdeadにする
      if (stabilityCounter[row][col] >= 5) {
        newState = 0;
      }

      nextGrid[row][col] = newState;

      // 生存セルのカウント
      if (newState === 1) {
        aliveCount++;
      }
    }
  }

  // グリッドを更新
  for (let row = 0; row < gridSize; row++) {
    for (let col = 0; col < gridSize; col++) {
      grid[row][col] = nextGrid[row][col];
      updateCell(row, col);  // 各セルの描画を更新
    }
  }

  // すべてのセルがdeadになった場合
  if (aliveCount === 0) {
    messageElement.textContent = "休憩終了";
  }
}

// 隣接する生存セルの数をカウント
function countAliveNeighbors(row, col) {
  let count = 0;
  for (let i = -1; i <= 1; i++) {
    for (let j = -1; j <= 1; j++) {
      if (i === 0 && j === 0) continue;
      const newRow = row + i;
      const newCol = col + j;
      if (newRow >= 0 && newRow < gridSize && newCol >= 0 && newCol < gridSize) {
        count += grid[newRow][newCol];
      }
    }
  }
  return count;
}

// ゲーム開始
function startGame() {
  if (!intervalId) {
    intervalId = setInterval(() => {
      calculateNextGeneration();
    }, 100);
  }
}

// ゲームリセット
function resetGame() {
  clearInterval(intervalId);
  intervalId = null;
  createGrid(); // リセット時にグリッドを再作成し、ランダムに6つのセルを黒くする
}

// イベントリスナー
startButton.addEventListener('click', startGame);
resetButton.addEventListener('click', resetGame);

// 初期グリッド作成
createGrid();
```
</details>

- 初期の画面
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/925407ce-e789-6467-5044-d6ce033cfe3b.png)

- 終了時の画面
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/bf9ec916-0cad-7862-29eb-3b0747bf1e90.png)


### 4.プロジェクトの実行
プロジェクトのルートディレクトリで以下を実行すればデスクトップ上に立ち上がります。

```bash:bash
npm start
```

## 完成
初めに紹介したように黒く塗られたセルがまるで生命のように動きます。

![無題の動画 (2).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/65b3c50a-4982-cc63-e004-61e46df05f05.gif)

![無題の動画 (3).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/93c464e0-9439-15f2-280d-767fd5847128.gif)



## 補足：Game of Lifeについて
[Wikipedia](https://ja.wikipedia.org/wiki/%E3%83%A9%E3%82%A4%E3%83%95%E3%82%B2%E3%83%BC%E3%83%A0)に詳しく書かれているのでここでは大事な点だけまとめます。
正式にはConway's Game of Lifeと言い、1970年にイギリスの数学者ジョン・ホートン・コンウェイ が考案した数理モデルです。

グリッドの中の黒く塗られたセルの配置によって次の世代を **「誕生・生存・過疎・過密」** のいずれかの状態に分類します。(正確にはあるセルの次の状態をそのセルの周囲8つのセルの状況で決定する)


|状態 | ルール | 例      |
|:------------|:------------|:------------:|
| 誕生        | 死んでいるセルに隣接する生きたセルが3つなら、次の世代が誕生 |<img width="50" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/f03649ef-07f4-0528-1d03-2c5d1cf8682a.png">|
| 生存      | 生きているセルに隣接する生きたセルが2つか3つなら、次の世代で生存|<img width="50" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/18dd8c24-682b-f3ef-27b6-c9a38f2185e7.png">|
| 過疎      | 生きているセルに隣接する生きたセルが1つ以下なら、過疎で死滅|<img width="50" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/4d9fac66-b92a-7b63-fa66-8478b9c9dcc9.png">|
| 過密      | 生きているセルに隣接する生きたセルが4つ以上なら、過密で死滅|<img width="50" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/986141/d41fe3ea-087d-2061-e7f4-cc04844dc95e.png">|

:::note info
**誕生**とは白いセルを黒く塗ること、
**生存**とは黒いセルを黒いままにしておくこと、
そして**死滅**は黒いセルを白く塗ることを指します。
:::

したがって初期の配置のみでその後の世代が決定する。
次の世代の状態を計算方法は以下の通り。
```javascript
function calculateNextGeneration() {
  let aliveCount = 0;
  for (let row = 0; row < gridSize; row++) {
    for (let col = 0; col < gridSize; col++) {
      const aliveNeighbors = countAliveNeighbors(row, col);

      // セルの新しい状態を計算
      let newState = grid[row][col];
      if (grid[row][col] === 1) {
        // 生存ルール
        newState = aliveNeighbors === 2 || aliveNeighbors === 3 ? 1 : 0;
      } else {
        // 誕生ルール
        newState = aliveNeighbors === 3 ? 1 : 0;
      }

      // 状態が変わらない場合、安定性カウンタを増やす
      if (newState === grid[row][col]) {
        stabilityCounter[row][col]++;
      } else {
        stabilityCounter[row][col] = 0; // 状態が変わればカウンタをリセット
      }

      // 5ターン以上変化がないセルを強制的にdeadにする
      if (stabilityCounter[row][col] >= 5) {
        newState = 0;
      }

      nextGrid[row][col] = newState;

      // 生存セルのカウント
      if (newState === 1) {
        aliveCount++;
      }
    }
  }

  // グリッドを更新
  for (let row = 0; row < gridSize; row++) {
    for (let col = 0; col < gridSize; col++) {
      grid[row][col] = nextGrid[row][col];
      updateCell(row, col);  // 各セルの描画を更新
    }
  }

  // すべてのセルがdeadになった場合
  if (aliveCount === 0) {
    messageElement.textContent = "休憩終了";
  }
}
```