こんなものをつくります
デスクトップで動く休憩時間管理アプリです。
開始してから黒いセルがなくなるまで休憩できる!というルールになっています。
これは**Game of Life(ライフゲーム)**という数理モデルのシミュレーションです。黒いセルが生命の誕生から淘汰の過程を模していると言われています。ここでは、この黒いセルを残す間は休憩時間を取ることができる、というデスクトップアプリにしました。
実装方法はさておき、まず体験してみたいという方はこちらからどうぞ!
Electronでデスクトップアプリをつくる
この記事では実装面としてElectronについて主に解説します。
Game of Lifeについては後半に補足しておきます。
Electronの基礎
Electronは、JavaScript、HTML、CSS によるデスクトップアプリケーションを構築するフレームワークです。GitHubが開発・管理をしており、ChrommiumとNode.jsで構成されています。
Electronで開発された代表的なサービスの一例です。
実装
ステップは次の通りです。
- Node.jsのインストール
- Electronのインストール
- スクリプトの準備
- プロジェクトの実行
また、最終的なプロジェクトのディレクトリ構成は次のようになります。
sample-app.
│ index.html
│ main.js
│ script.js
│ style.css
│ package.json
│ preload.js
│
└─node_modules
1.Node.jsのインストール
以下のコードでnodeとnpmのバージョンが表示されたらOKです。
node -v
#v20.17.0
npm -v
#10.8.2
もしNode.jsがなければ公式ページよりインストールしてください。インストール後はバージョンが表示されるか再度確認することをおすすめします。
2.Electronのインストール
新しいプロジェクトフォルダを作成し、npmプロジェクトを初期化します。
mkdir sample-app
cd sample-app
npm init -y
なお、nmp
はパッケージ管理ツールで、init
で初期化することで、package.json
が作成されます。
続いて、同じディレクトリでElectronをインストールします。
npm install --save-dev electron
3.スクリプトの準備
①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
に変更する必要があります。
{
"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です!
グリッドとボタンを準備しておきます。
<!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>
④style.css
重要なのは下から二番目の.cell.alive
です。JavaScriptでalive
クラスをon/offすることでセルの塗りつぶしを制御します。
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のコードですが、こちらは長いので折りたたんでおきます。簡単にすると次のルールで進行させています。
- 初めに6マスの集まり(生命)を黒く塗りつぶす
- さらにセルをクリックすれば追加で好きな数を塗りつぶすことができる
- 「休憩開始」を押すと生命のスタート ※規則はGame of Lifeの説明で補足します
- 動きのない生命は5ターンを経過すると消滅する
- すべてのセルが白くなったら(生命消滅)、「休憩終了」と表示される
- 「もう一度」ボタンを押すと1に戻る
ソースコード
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();
4.プロジェクトの実行
プロジェクトのルートディレクトリで以下を実行すればデスクトップ上に立ち上がります。
npm start
完成
初めに紹介したように黒く塗られたセルがまるで生命のように動きます。
補足: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 = "休憩終了";
}
}