はじめに
超ミニ4X(シヴィライゼーション風のターン制経済シミュレーション)ブラウザゲーム「小さな帝国」の開発連載、第3回です。
- サービスURL:https://satou20250828.github.io/micro-empire/
- リポジトリ:https://github.com/Satou20250828/micro-empire
| 回 | 内容 |
|---|---|
| #1 | 企画・課題発見 |
| #2 | 要件定義 |
| #3(本記事) | 設計(技術選定・状態設計・アーキテクチャ) |
| #4 | 開発①ゲームロジック実装編 |
| #5 | 開発②UI・画面実装編 |
| #6 | 開発③テスト・CI/CD構築編 |
| #7 | 開発中に遭遇した技術的問題 |
| #8 | 完成・振り返り |
前回(#2)で固まった要件を、実際にどう技術選定・状態設計・アーキテクチャに落とし込んだかを書きます。
技術選定
- Vite + Vanilla JS:Reactなどのフレームワークも検討しましたが、最終的にVanilla JSを選びました。理由はGitHub Pagesでの公開・運用のしやすさです。Webアプリケーションは今後も何本も作っていく予定のため、費用をかけずに、ビルド設定に手間をかけずに気軽に公開・非公開を切り替えられることを重視しました。Reactでも静的サイトとしてGitHub Pagesに公開すること自体は可能ですが、ルーティングやビルド設定など気にする点が増えます。素のJavaScriptをViteでビルドするだけで完結するVanilla JSの方が、シンプルにデプロイまで持っていけると判断しました
-
Tailwind CSS v4:CSS-first設定(
@theme)を採用。盤面の装飾表現にCSSが直接効く必要があったため、後述の通りCanvasは採用していません - Vitest:Vite系のテストランナーで導入コストが低く、CIとの統合もしやすいため採用
状態設計:DBを持たない構成をどう表現するか
本アプリはブラウザ完結型で、永続化ストレージ(DB)を持ちません。そのため一般的なER図は書けませんが、クライアント側で保持している状態オブジェクトの構造を整理することは、DBを持つアプリの状態設計と同じくらい重要でした。
主要な状態は以下の5つです。
-
Board:盤面の地形情報のみを保持し、労働者の位置情報は持たない -
Worker:自都市・CPU都市それぞれの労働者。row/colで現在地を表現し、盤面側とは疎結合 -
Resources/TechState:自軍・CPUでそれぞれ別インスタンスを持つ
ここで意識したのは、Board(地形)とWorker(誰がどこにいるか)を分離することです。#2で書いた通り、要件は「陣地の所有」から「労働者の配置」へと作り変わった経緯があり、この過程で「マスが誰のものか」という情報を状態から完全に削除しました。結果としてBoardは不変に近い静的データ、Workerは毎ターン動く可変データという役割分担がはっきりし、盤面側のコードを労働者の移動ロジックから独立させられました。
アーキテクチャ:状態は1箇所に集約し、モジュールは関数の集まりにする
状態設計と合わせて決めたのが、「状態を持つのはmain.jsだけにする」というルールです。
// main.js
const board = createBoard()
const resources = createResources()
const cpuResources = createResources()
const turnState = createTurnState()
const workers = createWorkers(board)
const playerTech = createTechState()
const cpuTech = createTechState()
let selectedWorkerId = null
let isTechTreeOpen = false
// ...UIの開閉状態などもここに集約
board.js/workers.js/resources.jsなどの各モジュールは、状態そのものを持たず、main.jsから渡された状態を受け取って処理するだけの関数の集まりとして実装しています。例えば労働者の移動ロジックはこうなっています。
// workers.js
export function getValidMoves(workers, worker, boardSize, moveLimit = DEFAULT_MOVE_LIMIT) {
if (worker.movedThisTurn >= moveLimit) return []
return getAdjacentCells(worker, boardSize).filter(
(pos) => !isOccupiedByOpponent(workers, worker.owner, pos.row, pos.col),
)
}
export function moveWorker(worker, row, col) {
worker.row = row
worker.col = col
worker.turnsAtPosition = 1
worker.movedThisTurn = (worker.movedThisTurn || 0) + 1
}
workersやworkerはすべて引数として渡され、workers.js自身はどの状態も抱えていません。この設計にした理由は、状態の出どころが1箇所(main.js)に決まっていれば、「今この値はどこで書き換えられているのか」を追うときにファイルを横断して探し回る必要がなくなるからです。
画面更新:render()関数への統一
もう一つの設計判断が、画面更新の方法です。状態が変わるたびに、全体を1つのrender()関数で描き直す方式にしました。
function render() {
const selectedWorker = getSelectedWorker()
const validMoves = selectedWorker
? getValidMoves(workers, selectedWorker, board.size, getPlayerMoveLimit())
: []
app.innerHTML = `
<main>
${renderResources(resources)}
${renderBoard(board, { workers, selectedWorkerId, validMoves })}
${isTechTreeOpen ? renderTechTreeModal(playerTech) : ''}
${isVictoryModalOpen ? renderVictoryModal(gameResult) : ''}
</main>
`
}
差分更新(変わった部分だけDOMを書き換える)の仕組みは作らず、状態が変わったらapp.innerHTMLをまるごと作り直しています。ユーザー操作は、盤面全体を覆う1つのclickイベントリスナーに集約し、クリックされた要素の種類(労働者のコマか、盤面のマスか、モーダルの閉じるボタンかなど)によって処理を振り分けるイベント委任の形にしました。
app.addEventListener('click', (event) => {
if (event.target.closest('#open-tech-tree')) {
isTechTreeOpen = true
render()
return
}
const workerButton = event.target.closest('[data-worker-id]')
if (workerButton) {
// ...労働者選択の処理
render()
return
}
// ...
})
5×5マス・15技術・複数モーダルという規模であれば、差分更新の仕組みを自作するコストよりも、「状態が変わったらrender()を呼ぶ」という単純なルールを徹底する方が、コードの見通しを保ちやすいと判断しました。
Canvasを使わなかった理由
盤面の描画方法として、Canvas(描画API)とHTML要素(div)の2択を検討しました。最終的にHTML要素での描画を選んだのは、Tailwind CSSによる装飾(色・枠線・ホバー時のハイライトなど)をそのまま適用できることを優先したためです。Canvasだとテキストや境界線の見た目を1から自前で描く必要がありますが、HTML要素であればTailwindのクラスを組み合わせるだけで、ラベル差分・状態に応じた色分けなどを表現できます。5×5マスというサイズであれば、Canvasの描画パフォーマンスを気にする必要もありませんでした。
次回予告
次回(#4)はゲームロジック実装編です。テックツリーの解放ロジックや?マスのランダム効果など、状態設計をどう具体的な処理に落とし込んだかを書きます。