はじめに
超ミニ4X(シヴィライゼーション風のターン制経済シミュレーション)ブラウザゲーム「小さな帝国」の開発連載、第4回です。
- サービスURL:https://satou20250828.github.io/micro-empire/
- リポジトリ:https://github.com/Satou20250828/micro-empire
| 回 | 内容 |
|---|---|
| #1 | 企画・課題発見 |
| #2 | 要件定義 |
| #3 | 設計(技術選定・状態設計・アーキテクチャ) |
| #4(本記事) | 開発①ゲームロジック実装編 |
| #5 | 開発②UI・画面実装編 |
| #6 | 開発③テスト・CI/CD構築編 |
| #7 | 開発中に遭遇した技術的問題 |
| #8 | 完成・振り返り |
前回(#3)で決めた「状態はmain.jsに集約し、各モジュールは状態を持たない関数の集まりにする」という設計を踏まえて、今回はゲームの中核ロジック(テックツリー・?マス・CPU AI・勝敗判定)の実装を書きます。
テックツリー:前提条件を配列の並び順で表現する
3系統×5段階、計15技術のテックツリーは、各系統を「前提条件でつながった一直線」として扱うため、配列の並び順そのものを前提条件として利用しています。
export const TECH_LINES = {
agriculture: {
label: '農業',
emoji: '🌾',
resource: 'food',
techs: [
{ name: '農耕', cost: 10, bonus: 1 },
{ name: '灌漑', cost: 20, bonus: 2 },
{ name: '肥料', cost: 35, bonus: 3 },
{ name: '品種改良', cost: 55, note: '3ターン滞在制限の撤廃' },
{ name: '農業革命', cost: 80, note: '労働者が3体目に増える' },
],
},
// architecture, currency も同様の5段階構成
}
export function getNextTech(techState, lineKey) {
const line = TECH_LINES[lineKey]
const unlockedCount = techState[lineKey]
if (unlockedCount >= line.techs.length) return null
return line.techs[unlockedCount]
}
techState[lineKey]は「その系統で何段階解放済みか」を表す数値だけを持ち、techState.agricultureが2であれば「次に解放できるのは配列のインデックス2=肥料」と機械的に導けます。個々の技術に「前提技術ID」のような参照を持たせる設計も検討しましたが、今回は一直線の系統が3本だけなので、配列のインデックスをそのまま前提条件として扱う方がコードがシンプルになると判断しました。
1〜3段階目と4〜5段階目で、効果の実装場所を分ける
15技術のうち1〜3段階目は「産出量+1〜+3」という数値効果で、techtree.js内のgetProductionBonus()で完結します。一方、4〜5段階目は「3ターン滞在制限の撤廃」「1ターン2マス移動」「?マスのマイナス効果消滅」など、他のモジュールのルールそのものを変える特殊効果です。
// techtree.js
// 1〜3段階目の産出ボーナス。4〜5段階目を解放していても数値ボーナスは3で頭打ち
// (4〜5段階目の特殊効果は各利用側でこのtechStateを直接参照して適用する)。
export function getProductionBonus(techState, resourceType) {
const lineKey = RESOURCE_TO_LINE[resourceType]
if (!lineKey) return 0
return Math.min(techState[lineKey], 3)
}
4〜5段階目の効果はtechtree.js側に集約せず、それぞれを使う側のモジュールに直接書きました。
-
workers.js:architecture >= 4なら1ターンの移動上限を2に(main.jsのgetPlayerMoveLimit()) -
workers.js:agriculture >= 4の陣営は3ターン滞在制限の対象から除外(getStayLimitExemptOwners()) -
mystery.js:architecture >= 5の陣営は?マスのマイナス効果が発生しない(getMysteryLossExemptOwners()) -
victory.js:currency >= 4で資源到達勝利のしきい値が20%下がる -
techtree.js:currency >= 5で農業/建築ラインの解放コストが20%引きになる
techtree.js自身に「この技術が解放されたら他のどこに影響するか」という一覧を持たせる案も考えましたが、効果ごとに影響範囲(移動ロジック、?マスロジック、勝利判定ロジック)がバラバラなため、無理に1箇所へ集約するとかえってtechtree.jsが肥大化します。各モジュールが自分の判断でtechStateを直接参照しに行く形にした方が、「このロジックのこの分岐は、どのテック解放と連動しているか」をそのモジュール内だけで完結して追えると判断しました。
?マス:重み付き抽選をテーブルで管理する
?マスの効果は「ボーナス」「変化なし」「ロス」の3種類を、確率テーブルに基づいて抽選しています。
const EFFECT_TABLE = [
{ type: 'bonus', weight: 50 },
{ type: 'nothing', weight: 30 },
{ type: 'loss', weight: 20 },
]
function pickWeightedType() {
const total = EFFECT_TABLE.reduce((sum, entry) => sum + entry.weight, 0)
let roll = Math.random() * total
for (const entry of EFFECT_TABLE) {
if (roll < entry.weight) return entry.type
roll -= entry.weight
}
return EFFECT_TABLE[EFFECT_TABLE.length - 1].type
}
確率をif文の連なりで書かずにテーブル(配列)として持たせているのは、後から確率バランスを調整する際に、コードの分岐構造を触らずにweightの数値だけを書き換えれば済むようにするためです。実際、この確率は50/30/20で固定していますが、「産業化」(建築5段階目)解放時にマイナス効果を無効化する処理は、この関数を呼び出すapplyMysteryEffects()側で「lossが出たらnothingに差し替える」という形にして、抽選ロジック自体には手を入れない設計にしました。
CPU簡易AI:新しいルールを増やさず、既存ロジックを再利用する
CPU AIは、プレイヤーと別の判断ロジックを新しく書くのではなく、既存の関数をそのまま呼び出す形で実装しています。
function moveCpuWorkers(board, workers, techState) {
const cpuWorkers = workers.filter((worker) => worker.owner === 'cpu')
const moveLimit = techState.architecture >= MACHINERY_TIER ? 2 : 1
cpuWorkers.forEach((worker) => {
while (worker.movedThisTurn < moveLimit) {
const currentTerrain = getCellTerrain(board, worker.row, worker.col)
if (RESOURCE_TERRAINS.includes(currentTerrain)) break
const candidates = getValidMoves(workers, worker, board.size, moveLimit)
if (candidates.length === 0) break
const resourceCandidates = candidates.filter((pos) =>
RESOURCE_TERRAINS.includes(getCellTerrain(board, pos.row, pos.col)),
)
const destination = pickRandom(resourceCandidates.length > 0 ? resourceCandidates : candidates)
moveWorker(worker, destination.row, destination.col)
}
})
}
移動可能マスの判定(getValidMoves)や実際の移動(moveWorker)は、プレイヤーの操作で使っているものと同じ関数です。CPU側で追加しているのは「資源マスにいるなら動かない」「資源マスの候補があればそこを優先する」という移動先の選び方だけで、移動ルール自体(隣接マスのみ/相手の労働者がいるマスには入れない/滞在ターン数の管理)はプレイヤーと完全に共通です。CPU専用のルールを増やさなかったことで、プレイヤー側の移動ルールを変更したときにCPU側の実装を書き換え忘れる、という不整合のリスクを避けられています。
勝敗判定:OR条件と、同時達成時の暫定仕様
勝利条件は「全テック解放」または「資源合計がしきい値到達」のOR判定で、checkVictory()が勝因を、determineWinner()が両陣営を比較して勝者を決めます。
export function checkVictory(resources, techState, difficulty = DEFAULT_DIFFICULTY) {
if (hasUnlockedAllTechs(techState)) return 'tech'
if (getResourceTotal(resources) >= getResourceThreshold(techState, difficulty)) return 'resource'
return null
}
// 自都市・CPU都市どちらが勝利条件を満たしたかを判定する。同じターンで両者が同時に
// 条件を満たした場合はプレイヤー優先とする(暫定仕様)。
export function determineWinner({ playerResources, playerTech, cpuResources, cpuTech, difficulty }) {
const playerReason = checkVictory(playerResources, playerTech, difficulty)
const cpuReason = checkVictory(cpuResources, cpuTech, difficulty)
if (playerReason) return { winner: 'player', reason: playerReason }
if (cpuReason) return { winner: 'cpu', reason: cpuReason }
return null
}
同じターンで自軍とCPUが同時に勝利条件を満たすケースは、公平な判定基準(例:達成した瞬間の資源量で比較する等)を作り込むこともできますが、実際に起きる頻度が低いと判断し、いったん「プレイヤー優先」という単純な仕様で確定させ、コメントに「暫定仕様」と明記しました。難易度によるしきい値(簡単80/普通115/難しい150)は、テックツリーを全解放するのに必要な資源合計(1系統あたり200、3系統で600)よりだいぶ早く到達できる水準を基準に決めています。
次回予告
次回(#5)はUI・画面実装編です。今回のロジック側の実装を、実際の画面(盤面・モーダル群)としてどう表示に落とし込んだかを書きます。