0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

15技術のテックツリーとCPU AIをどう実装したか【小さな帝国 開発連載#4】

0
Posted at

はじめに

超ミニ4X(シヴィライゼーション風のターン制経済シミュレーション)ブラウザゲーム「小さな帝国」の開発連載、第4回です。

回 内容
#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・画面実装編です。今回のロジック側の実装を、実際の画面(盤面・モーダル群)としてどう表示に落とし込んだかを書きます。

0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?