9
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2024-10-18

image.png

こんなものをつくります

無題の動画 (1).gif

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

  • 休憩中
    image.png

  • 休憩終了
    image.png

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

実装方法はさておき、まず体験してみたいという方はこちらからどうぞ!

Electronでデスクトップアプリをつくる

この記事では実装面としてElectronについて主に解説します。
Game of Lifeについては後半に補足しておきます。

Electronの基礎

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

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

image.png

実装

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

  1. Node.jsのインストール
  2. Electronのインストール
  3. スクリプトの準備
  4. プロジェクトの実行

また、最終的なプロジェクトのディレクトリ構成は次のようになります。

bash
sample-app.
│  index.html
│  main.js
│  script.js
│  style.css
│  package.json
│  preload.js
│
└─node_modules

1.Node.jsのインストール

以下のコードでnodeとnpmのバージョンが表示されたらOKです。

bash
node -v
#v20.17.0
npm -v
#10.8.2

もしNode.jsがなければ公式ページよりインストールしてください。インストール後はバージョンが表示されるか再度確認することをおすすめします。

2.Electronのインストール

新しいプロジェクトフォルダを作成し、npmプロジェクトを初期化します。

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

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

続いて、同じディレクトリでElectronをインストールします。

bash
npm install --save-dev electron

3.スクリプトの準備

①main.js

まず、プロジェクトのルートディレクトリにmain.jsファイルを作成します。

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に変更する必要があります。

package.json
{
  "name": "sample-app",
  "version": "1.0.0",
  "description": "",
  "main": "main.js",
  "scripts": {
    "start": "electron ."
  },
  "devDependencies": {
    "electron": "^latest"
  }
}

Electronのインストールが上手くいっていれば、devDependencies内に記述されます。

③index.html

ここからはwebアプリをつくるのと同様の作法でHTML/CSS/JavaScriptのファイルをつくればOKです!

グリッドとボタンを準備しておきます。

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

④style.css

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

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のコードですが、こちらは長いので折りたたんでおきます。簡単にすると次のルールで進行させています。

  1. 初めに6マスの集まり(生命)を黒く塗りつぶす
  2. さらにセルをクリックすれば追加で好きな数を塗りつぶすことができる
  3. 「休憩開始」を押すと生命のスタート ※規則はGame of Lifeの説明で補足します
  4. 動きのない生命は5ターンを経過すると消滅する
  5. すべてのセルが白くなったら(生命消滅)、「休憩終了」と表示される
  6. 「もう一度」ボタンを押すと1に戻る
ソースコード
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();
  • 初期の画面
    image.png

  • 終了時の画面
    image.png

4.プロジェクトの実行

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

bash
npm start

完成

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

無題の動画 (2).gif

無題の動画 (3).gif

補足:Game of Lifeについて

Wikipediaに詳しく書かれているのでここでは大事な点だけまとめます。
正式にはConway's Game of Lifeと言い、1970年にイギリスの数学者ジョン・ホートン・コンウェイ が考案した数理モデルです。

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

状態 ルール
誕生 死んでいるセルに隣接する生きたセルが3つなら、次の世代が誕生
生存 生きているセルに隣接する生きたセルが2つか3つなら、次の世代で生存
過疎 生きているセルに隣接する生きたセルが1つ以下なら、過疎で死滅
過密 生きているセルに隣接する生きたセルが4つ以上なら、過密で死滅

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

したがって初期の配置のみでその後の世代が決定する。
次の世代の状態を計算方法は以下の通り。

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 = "休憩終了";
  }
}
9
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?