はじめに
超ミニ4X(シヴィライゼーション風のターン制経済シミュレーション)ブラウザゲーム「小さな帝国」の開発連載、第5回です。
- サービスURL:https://satou20250828.github.io/micro-empire/
- リポジトリ:https://github.com/Satou20250828/micro-empire
| 回 | 内容 |
|---|---|
| #1 | 企画・課題発見 |
| #2 | 要件定義 |
| #3 | 設計(技術選定・状態設計・アーキテクチャ) |
| #4 | 開発①ゲームロジック実装編 |
| #5(本記事) | 開発②UI・画面実装編 |
| #6 | 開発③テスト・CI/CD構築編 |
| #7 | 開発中に遭遇した技術的問題 |
| #8 | 完成・振り返り |
前回(#4)のゲームロジックを、実際の画面(盤面・モーダル群)としてどう表示に落とし込んだかを書きます。
レスポンシブ対応:メディアクエリを書かずにclamp()で済ませる
盤面のサイズ調整は、メディアクエリを1つも書かずにclamp()だけで対応しています。
// board.js
export function renderBoard(board, { workers = [], selectedWorkerId = null, validMoves = [] } = {}) {
return `
<div class="mx-auto grid w-[clamp(320px,90vw,580px)] grid-cols-5 gap-1.5">
...
</div>
`
}
clamp(320px, 90vw, 580px)は「最小320px・基準は画面幅の90%・最大580px」という意味です。スマホの狭い画面では320pxを下限にしつつ画面幅いっぱいに近い比率で表示し、PCの広い画面では580pxで頭打ちにしてマス目が間延びしないようにしています。5×5マスの正方形グリッドはgrid-cols-5とaspect-square(各セル側)の組み合わせだけで縦横比を保っているため、幅さえ決まればレイアウトは崩れません。労働者のコマも同様に、h-8 w-8(基本)とsm:h-10 sm:w-10(画面が広いとき)のようにTailwindのブレークポイント接頭辞だけでサイズを切り替えており、JS側で画面幅を判定するコードは書いていません。
モーダル4つの排他制御
テックツリー・CPU状況・ルール確認・勝敗結果の4つのモーダルは、開閉状態をmain.js側の4つの真偽値で管理し、いずれか1つを開くときに他をすべて閉じる形にしています。
// main.js
if (event.target.closest('#open-tech-tree')) {
isCpuStatusOpen = false
isRulesOpen = false
isTechTreeOpen = true
render()
return
}
モーダルの表示自体はrender()側で「開いているものだけを描画する」という単純な条件式です。
${isTechTreeOpen ? renderTechTreeModal(playerTech) : ''}
${isCpuStatusOpen ? renderCpuStatusModal(cpuResources, cpuTech) : ''}
${isVictoryModalOpen ? renderVictoryModal(gameResult) : ''}
${isRulesOpen ? renderRulesModal(difficulty) : ''}
複数のモーダルを重ねて開けるようにする設計も考えられますが、今回はモーダル同士に関連がなく同時に見る必要もないため、常に1つしか開かない仕様に絞り、状態管理をシンプルに保っています。閉じる操作も、✕ボタンのクリックとモーダル背景(id="tech-tree-modal"自身)のクリックの両方を許可し、どちらでも同じように状態をfalseに戻すだけです。
移動可能マスのハイライト:枠線とオーバーレイの二重表現
労働者を選択すると、移動可能なマスに枠線とオーバーレイの両方でハイライトを表示しています。
const highlight = isValidMove ? 'ring-4 ring-inset ring-sky-500 animate-pulse' : ''
const highlightOverlay = isValidMove
? '<div class="pointer-events-none absolute inset-0 rounded-md bg-sky-400/40"></div>'
: ''
最初は枠線(ring)だけで実装していましたが、地形アイコンの背景色によっては枠線だけだと目立ちにくく、直感的に分かりづらいという課題がありました。そこで、点滅する枠線に加えて半透明の水色オーバーレイ(bg-sky-400/40)を重ね、どの地形色の上でも移動可能マスだと判別できるようにしています。オーバーレイにはpointer-events-noneを付け、下にある実際のクリック判定(data-row/data-colを持つセル自体)を邪魔しないようにしています。
CPU状況モーダル:テックツリーの閲覧専用版として実装
CPU状況モーダルのテックツリー表示部分は、プレイヤー用のテックツリーモーダル(techtree.jsのrenderTechTreeModal)とほぼ同じ見た目ですが、あえてcpuStatus.js側に閲覧専用の描画関数を別途用意しています。
// cpuStatus.js
// テックツリーモーダル(techtree.js)と見せ方は揃えつつ、解放操作はできない閲覧専用の表示。
function renderReadOnlyTechCard(tech, tierIndex, techState, lineKey) {
const tierNumber = tierIndex + 1
const isUnlocked = tierNumber <= techState[lineKey]
const statusLabel = isUnlocked ? '解放済み' : '未解放'
// ...解放ボタンを持たない、閲覧専用のカードを組み立てる
}
techtree.js側のrenderTechCard()をisReadOnlyのようなフラグ付きで共通化する案も検討できますが、プレイヤー用は「解放可能なら解放ボタンを出す」という操作系のロジックを持つのに対し、CPU用は完全に見るだけです。1つの関数にフラグ分岐を持たせるより、見た目のクラス構成だけを揃えた別関数として分けた方が、それぞれの責務(操作可能な描画/閲覧専用の描画)を混在させずに済むと判断しました。
ルール確認モーダル:数値をロジック側から動的に取得する
ルール確認モーダルの勝利条件セクションは、難易度のしきい値をハードコードせず、victory.jsが持つDIFFICULTY_LEVELSを直接参照して文言を組み立てています。
// rules.js
import { DIFFICULTY_LEVELS } from './victory.js'
function buildSections(currentDifficulty) {
const thresholdList = Object.values(DIFFICULTY_LEVELS)
.map((level) => `${level.label}${level.threshold}`)
.join('/')
const currentLabel = DIFFICULTY_LEVELS[currentDifficulty].label
// ...
}
しきい値の数値をルール文言側に直接書いてしまうと、後からvictory.js側の数値だけを調整したときに、説明文が実際の挙動とずれてしまいます。rules.jsは表示の対象になる数値を一切持たず、常にvictory.jsの値を参照する形にすることで、ロジックと説明文が食い違うリスクをなくしています。
次回予告
次回(#6)はテスト・CI/CD構築編です。ランダム性のあるロジック(?マスの抽選など)をどうテストしたか、GitHub Actionsでのlint・test・デプロイの自動化について書きます。