ターン制バトル RPG ― .NET Framework 4.8 制作チュートリアル

2025年8月5日

プログラミング学習を一歩先へ――本記事では .NET Framework 4.8 と Visual Studio 2022 を用い、コンソール上で動く最小構成の ターン制バトル RPG を組み立てながら、オブジェクト指向の核心となるカプセル化(情報隠蔽)継承ポリモーフィズム(動的な振る舞いの差し替え)、コンポジション(部品の組み合わせ)」 を体験的に学びます。対象は C# の基礎構文を習得したばかりの初学者です。単なるサンプルコードの羅列ではなく、フォルダー構成・クラス設計・LINQ を使ったコードリーディングの勘所まで、実際の開発フローに則って解説していきます。

手を動かしながら学ぶ」をコンセプトに、プロジェクト作成からビルド&実行、さらには発展課題までを段階的に示しているため、記事を読み進めるだけで コンパイル直後から実行できる短いサンプルゲーム」 を完成させることができます。エラーと付き合いながら理解を深める“現場感覚”を味わいたい方は、ぜひ Visual Studio を立ち上げ、本記事を片手にキーボードを叩いてみてください。

目次

チュートリアル

0. 目的と前提

  • 目的: コンソールで動く最小構成のターン制バトル RPG を完成させながら、カプセル化・継承・ポリモーフィズム・コンポジションを体験します
  • 開発環境: Visual Studio 2022(Community 版可) / .NET Framework 4.8
  • 対象: C# の基礎構文を学び終わった初学者

1. プロジェクトを作成する

手順操作
1ファイル ▸ 新規作成 ▸ プロジェクト
2テンプレート 「コンソール アプリ (.NET Framework)」 を選ぶ
3プロジェクト名: TurnBasedRpgFw48
4フレームワーク.NET Framework 4.8
5 [作成] をクリックします

2. フォルダーを作成する

フォルダーやクラスを追加するときは、左ペインの “ソリューション エクスプローラー” でプロジェクト名を右クリック

  1. プロジェクト TurnBasedRpgFw48 を右クリック → [追加 ]▸[ 新しいフォルダー]を選択します
    • Models
    • Services
  2. Models を右クリック → [追加] ▸ [クラス] を選択します
    • Character.cs
    • Player.cs
    • Enemy.cs
  3. Services を右クリック → [追加] ▸ [クラス] を選択します
    • Battle.cs

完成イメージ

TurnBasedRpgFw48/
├─ Models/
│  ├─ Character.cs
│  ├─ Player.cs
│  └─ Enemy.cs
├─ Services/
│  └─ Battle.cs
└─ Program.cs

Models と Services に分ける」という最小限のレイヤリングは、責務(Responsibility)をはっきり分離し、実装の見通しを良くするためです。


フォルダー分割がもたらす 4 つのメリット

観点具体的効果
1. 可読性「キャラクターの属性を触りたい→Models フォルダーを開く」「バトルロジックを変えたい→Services フォルダーを開く」と着地点が一瞬で分かる
2. 再利用・拡張性Battle が UI に依存しないため、あとから WinForms/WPF/Unity など別 UI へ差し替えてもモデル層をそのまま流用できる。
3. テスト容易性ドメインとロジックが切れているので、たとえば Battle のユニットテスト時に ダミー Player / Enemy を注入しやすい。
4. コンフリクト削減チーム開発で「モデル班」「サービス班」に分かれても 同じファイルを同時編集しづらくなるため、Git 衝突を抑制。

このプロジェクトでの具体的な役割

フォルダー代表クラス責務 (SRP)主要パターン
ModelsCharacter, Player, Enemy「名前・HP・攻撃力」などデータとその最小限の操作を保持基底クラス+継承 によるポリモーフィズム
ServicesBattle複数の Character を束ねてターン制を進行する手続きDI(コンストラクタ注入)テンプレートメソッド 風
(ルート)Programアプリの起動と終了を司るだけComposition Root

実務的フォルダー設計のヒント

  1. ルールは「名詞⇔動詞」
    • ドメインオブジェクト(名詞)→ ModelsEntities
    • ビジネスロジック(動詞)→ ServicesUseCases
  2. UI は別プロジェクトにしても◎
    • TurnBasedRpgFw48.WinForms などに分ければ DLL 参照で済む。
  3. 名称は物理フォルダと合わせる
    • 名前空間 TurnBasedRpgFw48.Models ↔ フォルダー Models
    • Visual Studio の “同期” 機能で自動補完。
  4. 階層は多すぎないこと
    • 初学者教材では 2~3 階層が限界。複雑化は学習コストが跳ね上がる。
  5. 将来の変更に備えた“疎結合”
    • Models は Services に依存しない(一方向依存)。
    • 逆依存が必要になったらインターフェース経由にする。

まとめ

  • Models ⇔ Services の 2 分割だけでも「責務の境界」を明確にできる。
  • この記事では早い段階でフォルダーを切ることで、後工程の「テスト拡充」「UI 分離」「機能追加」を楽にする布石になっている。
  • 規模が大きくなったら Repositories / Infrastructure を追加し、レイヤード・アーキテクチャへ発展させるのが定石。

まずは記事の通りにフォルダーを作成し、“場所を探す時間”をゼロにする開発体験を味わってみてください。


3. モデル層を実装する(Models)

3-1 Character.cs(抽象基底クラス)

using System;

namespace TurnBasedRpgFw48.Models
{
    public abstract class Character
    {
        public string Name { get; }
        public int HP { get; private set; }
        public int Atk { get; }

        protected Character(string name, int hp, int atk)
        {
            Name = name;
            HP = hp;
            Atk = atk;
        }

        public void TakeDamage(int amount)
        {
            HP = Math.Max(0, HP - amount);
            Console.WriteLine($"{Name} は {amount} ダメージ!(残 HP: {HP})");
        }

        public bool IsAlive => HP > 0;

        public abstract void Act(Character opponent);
    }
}

3-2 Player.cs

using System;

namespace TurnBasedRpgFw48.Models
{
    public class Player : Character
    {
        public Player(string name) : base(name, 40, 10) { }

        public override void Act(Character opponent)
        {
            Console.WriteLine($"▶ {Name} のターン:攻撃!");
            opponent.TakeDamage(Atk);
        }
    }
}
部分意味初学者向けポイント
publicアクセス修飾子。どこからでも Player のインスタンスを生成できるライブラリ外からも呼び出し可能
Player(string name)Player クラスの コンストラクター(戻り値なし、クラスと同名)引数 name でプレイヤーの名前を受け取るnew Player(“アリス") と書いた瞬間に実行されるメソッド
: base(name, 40, 10)基底クラス Character のコンストラクターを呼び出す「コンストラクター初期化子」  1. name … 呼び出し元から受け取った名前  2. 40 … 初期 HP(体力)  3. 10 … 初期 Atk(攻撃力)継承チェーンを明示:Character には引数なしコンストラクターが無いので、必ず引数付きで呼ぶ必要がある
{ }本クラス固有の追加初期化コード――ここでは無し“何もしない” も選択肢:基底クラスで十分に初期化できるため

なぜ base(…) が必要なのか

  • Character のコンストラクターはprotected Character(string name, int hp, int atk) の 1 つだけ。引数なし(パラメータレス)のコンストラクターが存在しないため、派生側 (Player) は 必ずどれかの Character(…) を呼び出さなければコンパイルエラー になります。 
  • base(…) は Java の super(…) に相当。最初に実行され、Name / HP / Atk プロパティをセットします。
// Character 側
protected Character(string name, int hp, int atk)
{
    Name = name;   // 読み取り専用プロパティ
    HP   = hp;     // private set なのでここでしか代入できない
    Atk  = atk;    // 初期化必須
}

「40」と「10」はどこで決める?

役割なぜここに書く?
40プレイヤーの初期 HP「ゲームバランスの定数」をコードにベタ書き後で調整したいなら const int DefaultHp = 40; などに切り出す
10プレイヤーの初期 Atk同上。将来 難易度別コンストラクターを用意するのも一手

処理の流れ(コンストラクターチェーン)

  1. new Player(“アリス")
  2. Player コンストラクター開始
  3. base(“アリス", 40, 10) で Character コンストラクター実行
    • Name = “アリス"
    • HP   = 40
    • Atk  = 10
  4. Character の初期化完了 → Player コンストラクター本体(空)へ戻る
  5. インスタンス生成完了

: base(…) を書かなかったら?

public Player(string name) { }   // コンパイルエラー

CS7036: ‘Character.Character(string, int, int)’ に適合する引数がありません

  • C# のルール:派生クラスのコンストラクターは、暗黙に : base() を付けて呼び出す(引数なしの基底コンストラクターが存在する場合のみ有効)。
  • 今回は Character に引数なしコンストラクターが無いので、明示的に書く必要があるのです。

まとめ

  • public Player(string name) は プレイヤーを生成する窓口
  • : base(name, 40, 10) で 「HP 40・攻撃力 10 のプレイヤー」という初期状態を Character に一任。
  • 派生コンストラクターで追加ロジックが不要なら { } は空でも問題なし。
  • 継承では「どの基底コンストラクターを呼ぶか」を必ず意識しよう。

3-3 Enemy.cs

using System;

namespace TurnBasedRpgFw48.Models
{
    public class Enemy : Character
    {
        private static readonly Random Rng = new Random();

        public Enemy(string name) : base(name, 30, 8) { }

        public override void Act(Character opponent)
        {
            int dmg = Atk + Rng.Next(-2, 3);   // ±2 の「ばらつき」を加える
            Console.WriteLine($"◀ {Name} のターン:ランダム攻撃!");
            opponent.TakeDamage(dmg);
        }
    }
}
部分意味初学者向けポイント
publicアクセス修飾子。Battle など他クラスから自由に呼べる「インスタンス外から呼び出してOK」のサイン
override基底クラス Character の 抽象メソッド Act を上書き・ポリモーフィズムの核:Character 参照経由でも   Player/Enemy それぞれの実装が実行される ・コンパイル時チェック付きで「シグネチャが一致」
void戻り値なし行動結果(ダメージなど)は 副作用 で相手に反映する設計
Act(Character opponent)メソッド名+パラメータ・Act=「行動せよ」・opponent は 攻撃対象。型を Character にしておくと、  プレイヤーでも敵でも渡せる汎用 API

メソッド本体の 2 行

【コード】

Console.WriteLine($“▶ {Name} のターン:攻撃!”);<br>opponent.TakeDamage(Atk);

――――――――――――――――――――――――

■ 各行の役割

行 1 : 行動ログを表示して「誰が何をしたか」を即座に確認できるようにする。

行 2 : 相手(opponent)に攻撃力(Atk)ぶんの固定ダメージを与える。ダメージ計算は Character クラスで一元管理されている TakeDamage メソッドに任せるため、プレイヤーでも敵でも同じ手順で HP を減らせる。

――――――――――――――――――――――――

■ 呼び出しまでの流れ

  1. Battle.ExecuteTurn() が actor.Act(target) を実行する。
  2. そのターンの actor が Player の場合、動的ディスパッチによって Player.Act が呼ばれる。
  3. 行動ログを出力し、TakeDamage が相手の HP を減らす。0 未満になった場合は TakeDamage 内で 0 に丸められる。この一連の処理は 1 ターンあたり数ミリ秒で完了する。

――――――――――――――――――――――――

■ override が必要な理由

Character クラスには抽象メソッド Act() が宣言されており、派生クラスは必ず実装しなければならない。Player.Act に override を付けることで「契約を守っています」とコンパイラに伝え、シグネチャの食い違いによるバグを防ぐ。

――――――――――――――――――――――――

■ さらに発展させるなら

・クリティカルヒットの追加例

if (Rng.NextDouble() < 0.10) damage *= 2;

・攻撃/防御を選ばせる入力メニュー

 Console.ReadKey(true) を使ってキー入力を判定し、行動を分岐させる。

・スキルシステムの実装

 ISkill インターフェースを定義し、各スキルに Execute(Character target) を持たせて Player.Act 内で順に呼び出す。


4. サービス層を実装する(Services)

Battle.cs

using System;
using System.Collections.Generic;
using System.Linq;
using TurnBasedRpgFw48.Models;

namespace TurnBasedRpgFw48.Services
{
    public class Battle
    {
        private readonly List<Character> _actors;

        public Battle(params Character[] actors)
        {
            _actors = actors.ToList();
        }

        public void Run()
        {
            Console.WriteLine("=== バトル開始 ===");
            int turn = 1;

            while (_actors.All(a => a.IsAlive))
            {
                Console.WriteLine($"\n--- ターン {turn++} ---");
                ExecuteTurn();
            }

            Console.WriteLine("\n=== バトル終了 ===");
            foreach (var c in _actors)
                Console.WriteLine($"{c.Name} : {(c.IsAlive ? "勝利" : "敗北")}");
        }

        private void ExecuteTurn()
        {
            foreach (var actor in _actors.Where(a => a.IsAlive))
            {
                var target = _actors.First(a => a != actor && a.IsAlive);
                actor.Act(target);
                if (!target.IsAlive) break; // KO でターン打ち切り
            }
        }
    }
}

このコード:

public Battle(params Character[] actors)
{
    _actors = actors.ToList();
}

は、可変長引数を受け取るコンストラクタの例です。以下のように順を追って説明します。


🔹 概要

  • Battle クラスのコンストラクタ
  • 引数として Character 型の配列(params)を受け取り、それをリスト _actors に変換して保持する

🔸 params Character[] actors の意味

  • params 修飾子を使うことで、複数の Character をカンマ区切りで渡すことができます。
  • 呼び出し側は 配列を明示的に作らずに 複数の引数を渡せます。

例(呼び出し側):

var player = new Player("アリス");
var enemy = new Enemy("スライム");

var battle = new Battle(player, enemy);

↑ このように、配列ではなく複数のオブジェクトをそのまま渡せるのが params の利点です。


🔸 _actors = actors.ToList(); の意味

  • params で渡された Character[] は配列なので、ToList() を使ってリストに変換しています。
  • _actors はおそらく List<Character> 型のフィールドであり、バトル参加者を保持するために使用されます。

🔹 まとめ

要素説明
params呼び出し側が任意の数の引数を渡せるようにする
Character[] actors可変長で受け取った Character 型の配列
ToList()配列を List<Character> に変換
_actorsBattle インスタンス内でバトル参加キャラを保持するリスト

🔸 参考:配列で渡すことも可能

もちろん、以下のように配列を渡すこともできます:

Character[] characters = { player, enemy };
var battle = new Battle(characters);

はい、こちらのコード:

while (_actors.All(a => a.IsAlive))
{
    Console.WriteLine($"\n--- ターン {turn++} ---");
                ExecuteTurn();
            }

についても、初心者向けにわかりやすく説明します。


🔹 このコードの役割

この while 文は、

すべてのキャラクターが生きている間だけ、ループを続ける

という意味です。


🔸 

_actors.All(a => a.IsAlive)

 の意味

これは LINQ(リンク) という機能を使った条件判定です。

構文意味
_actorsバトルに参加しているキャラクターのリスト(List<Character>)
.All(…)中のすべての要素が条件を満たすかどうかをチェック
a => a.IsAlive各キャラクター a に対して、IsAlive が true か確認

つまり:

すべてのキャラクター(a)の a.IsAlive が true なら true を返す

🔸 

IsAlive

 プロパティの意味(補足)

例えば Character クラスにこういうコードがあるとします:

public bool IsAlive => HP > 0;

これは「HP が 0 より大きいなら生きている」と判断します。


🔹 while文としての動作

while (_actors.All(a => a.IsAlive))
{
    // バトルの処理を行う
}

このループはこう読み替えられます:

全員が生きているうちはバトルを続ける。誰かが死んだら(HPが0になったら)バトルを止める。


🔸 図でイメージ

Actors: [ 勇者(HP 10), スライム(HP 20) ] → 全員 IsAlive → ループ継続
Actors: [ 勇者(HP 0), スライム(HP 5) ] → 1人死亡 → ループ終了

🔹 まとめ

部分意味
while条件が true の間、繰り返す
_actors.All(…)すべてのキャラが条件を満たしているかチェック
a => a.IsAlive各キャラの生死をチェック
結果誰か1人でも死んだらバトルを止める
断片役割詳細・ポイント
foreach (列挙構文の開始。IEnumerable<T> を 1 件ずつ取り出してループを回す C# キーワード。裏では GetEnumerator() → MoveNext() → Current が呼ばれる。
var actorループ変数。var は暗黙型推論で、ここでは Character 型に展開される(_actors が List<Character> だから)。
in「列挙対象はこれです」という区切りワード。
_actors戦闘参加者リスト。型は List<Character>(IEnumerable<Character> として扱われる)。
.Where(LINQ 拡張メソッド。コレクションを フィルタリング して、新しい遅延列挙 (IEnumerable<Character>) を返す。
a => a.IsAliveラムダ式(デリゲート)で条件を指定。Character a が 生存 (IsAlive == true) のときだけ通す。
)Where 呼び出しの終端。
)(foreachの終端)foreach ヘッダの閉じ。続く { … } がループ本体。

1. 実際の動作フロー

  1. 遅延列挙の生成
IEnumerable<Character> alive = _actors.Where(a => a.IsAlive);
  1. ここではまだ走査されず、“条件だけ覚えたパイプ” が返る。
  2. foreach 開始で列挙がスタート
    • MoveNext() が呼ばれるたび _actors の次要素を取り出し、ラムダ条件を評価。
    • IsAlive == false の要素はスキップされ、条件を満たす最初の要素だけが Current として actor へ渡る。
  3. ループ本体で actor.Act(target); などの処理を実行。
  4. 以後 MoveNext() ⇒ 条件判定 ⇒ ヒットしたら本体・・・を 生存者が尽きるまで繰り返す

2. “LINQ あり” と “if スキップ” 版の等価性

// LINQ(宣言的)
foreach (var actor in _actors.Where(a => a.IsAlive))
{
    ...
}

// 伝統的(命令的)
foreach (Character actor in _actors)
{
    if (!actor.IsAlive) continue;
    ...
}
  • パフォーマンスはどちらも O(n)
  • LINQ 版は「生きているキャラだけ」と意図が先に読めるのが利点。
  • 追加メモリは不要(遅延実行なので中間リストを作らない)。

3. 遅延実行ゆえの注意

状況結果
ループ中に actor.TakeDamage(999) で死亡させる今取り出した要素は処理を続行。次回 MoveNext() で再評価される要素は除外される。
ループ中に _actors.Add(…) / Remove(…)InvalidOperationException。列挙中コレクション変更は不可。必要なら .ToList() でスナップショットを取る。

4. var を明示型にしたい場合

foreach (Character actor in _actors.Where(a => a.IsAlive))
  • 型が一目で分かる安心感を優先。
  • ただし LINQ 連鎖が複雑になると毎回書くのが煩雑なので、var + マウスホバーで型確認する方法も実用的。

5. ひと言まとめ

宣言的に「生きているキャラだけを順番に処理する」

LINQ の Where + foreach は

  • 条件フィルタとループを 1 行 で記述
  • 遅延実行で無駄なメモリ割当なし
  • コードを「意図 → 処理」の順に読める

こうした利点があるため、ゲームロジックでも「状態で絞り込んで処理する」場面で頻出します。

切片していること補足ポイント
var target返ってきた要素を受け取る 変数宣言var の実際の型は Character(_actors が List<Character> のため)
=代入
_actorsループと同じ 戦闘参加者リスト型は List<Character>
.First(LINQ 拡張メソッド。条件を満たす最初の 1 件を返す見つからないと InvalidOperationException が発生
a => a != actor && a.IsAliveラムダ式(Func<Character,bool>)– a != actor : 今行動中の本人は除外- a.IsAlive : 生存しているものだけ対象
)閉じカッコ

1. フロー全体

  1. _actors 内部を 先頭から順に評価
  2. 条件 (a != actor && a.IsAlive) を満たす 1 件目を発見した瞬間に返す
  3. 変数 target にその Character が入る
  4. 該当要素が無ければ InvalidOperationException がスローされ、バトルが落ちる

安全性メモ

  • 本チュートリアルでは “生存者が自分だけ” という状況で ExecuteTurn を呼ばない設計なので例外は起きない想定。
  • 不安な場合は FirstOrDefault を使い、null チェックでフォールバック処理を書くと堅牢。

2. “LINQ あり” と “for ループ” の等価コード

// LINQ 版
Character target = _actors.First(a => a != actor && a.IsAlive);

// 伝統的 for 版
Character target = null;
for (int i = 0; i < _actors.Count; i++)
{
    var c = _actors[i];
    if (c != actor && c.IsAlive)
    {
        target = c;
        break;
    }
}
if (target == null) throw new InvalidOperationException();
  • 簡潔さ: LINQ 1 行で “最初の生存敵を取る” 意図がはっきり
  • コスト: 両者とも最悪 O(n)。First は途中で打ち切り可なので無駄走査なし

3. .First/.FirstOrDefault 選択ガイド

メソッド戻り値なし時適用場面
First例外 (InvalidOperationException) を投げる「必ず存在する」契約になっているとき
FirstOrDefault参照型なら null、値型ならデフォルト値0 件あり得るとき。返却後に null チェック
var target = _actors.FirstOrDefault(a => a != actor && a.IsAlive);
if (target == null) return; // 行動せずターン終了など

4. “遅延実行” の影響

  • First も 遅延実行:呼ばれた瞬間にだけ列挙を開始し、1 件取ったらそこでストップ
  • したがって他スレッドで _actors が変更されても、最初に列挙し終わった要素には影響しない
  • ただし 列挙中に Remove や Add を行うと InvalidOperationException が出るので注意(foreach と同じルール)

5. varを明示的に変えた場合

Character target = _actors.First(a => a != actor && a.IsAlive);
  • 可読性を優先して明示型にしても性能差はない
  • LINQ 連鎖で型が変わる場合は var + マウスホバーで確認が実用的

6. まとめ ― キーアイデア

“コレクションから条件に合う先頭要素を 1 つだけ取る”

  • First は「無いときは落ちて良い」場面で強い宣言力
  • 0 件許容なら FirstOrDefault+null 判定
  • 宣言的コードで意図が明確、ループ途中で止まるため効率的

Where+foreach と First の両方を理解すれば、LINQ で

フィルタ → 先頭取得 → 操作」のパイプラインを読み解く基礎はカバーできます。


5. エントリーポイント(Program.cs)

using System;
using TurnBasedRpgFw48.Models;
using TurnBasedRpgFw48.Services;

namespace TurnBasedRpgFw48
{
    internal static class Program
    {
        private static void Main()
        {
            var battle = new Battle(
                new Player("勇者アリス"),
                new Enemy("スライム")
            );

            battle.Run();

            Console.WriteLine("\nEnter で終了…");
            Console.ReadLine();
        }
    }
}

1. using ディレクティブ

役割
using System;標準ライブラリの System 名前空間(Console などの基本 API)を参照します。
using TurnBasedRpgFw48.Models;ドメインモデル(Player, Enemy, Character など)が入った Models 名前空間をインポートします。
using TurnBasedRpgFw48.Services;ゲームロジックをまとめた Services 名前空間。ここでは Battle クラスを利用します。

ポイント

名前空間を物理フォルダ構成と合わせておくと、巨大プロジェクトでも型の所在が一目で分かります。


2. namespace TurnBasedRpgFw48

  • プロジェクト全体を包む最上位名前空間。
  • 同じ接頭辞を共有することで ディスカバリ(見つけやすさ) と 衝突回避 を両立します。

3. internal static class Program

修飾子意味このファイルでの意図
internal同一アセンブリ内に公開。テスト用の別プロジェクトには露出しない。コンソールアプリなので外部から触る必要がない。
staticインスタンス化不可。メンバーはすべて static。エントリポイント だけを提供するユーティリティ的クラスだから。

4. private static void Main()

  • CLR が最初に呼び出すメソッド。アクセス修飾子は private でも問題ありません(外部から呼ばれないため)。
  • 戻り値を void にすると終了コードを返さないシンプルな形になります(整数を返して OS に終了コードを渡すことも可)。

5. バトルの生成と実行

var battle = new Battle(
    new Player("勇者アリス"),
    new Enemy("スライム")
);
構成要素説明
Battle1 対 1 の戦闘を司るサービスクラス(Single Responsibility)。コンストラクター DI の形で参加者を受け取るため、テストダブルを差し替えやすい。
new Player(“勇者アリス")プレイヤー派生クラス。Character 抽象基底クラスの共通 API(HP, 攻撃力 など)を継承。
new Enemy(“スライム")敵キャラクター派生クラス。プレイヤーと同じインターフェースで扱えるので ポリモーフィズム を実現。
battle.Run();
  • Battle が内部ループを持ち、参加者の Act() を交互に呼び出して決着まで制御します。
  • UI(コンソール出力)の責務も Battle 側に寄せれば MVC 的に Model と Presentation を分離できます。

6. 終了待機

Console.WriteLine("\nEnter で終了…");
Console.ReadLine();
  • ゲーム終了直後にコンソールが即座に閉じるのを防ぐ典型パターン。
  • ReadLine() はブロッキングなので、ユーザーが Enter を押すまでアプリが維持されます。

全体の流れ(シーケンス)

  1. Main が呼ばれる
  2. Player / Enemy を組み立てて Battle インスタンスを生成
  3. Battle.Run() が内部でターン制ループを実行
  4. 勝敗決定 → Run() 戻り → メインスレッドへ
  5. ユーザーが Enter を押すとプロセス終了

さらに改善できる点

改善案メリット
Main の戻り値を int にして終了コードを返すバッチ処理などで成功/失敗を OS へ通知できる
ロギングライブラリ(Serilog など)を注入戦闘ログをファイルや GUI へ多重出力しやすい
DI コンテナ(Microsoft.Extensions.DependencyInjection)導入テストや将来の UI(WinForms/WPF/WebGL)差し替えが簡単
ICharacter インターフェースを設けて Player / Enemy を実装NPC 追加時も Battle のコードを触らず拡張可能(Open/Closed 原則)

これで Program.cs が果たす役割と、クラス設計全体の意図がクリアになるはずです。必要に応じてリファクタリングやテスト拡充を行ってみてください。


6. ビルド & 実行

  1. ビルド ▸ ソリューションのビルド
  2. Ctrl + F5(デバッグなし開始)
  3. コンソールにバトルログが流れ、勝敗が表示されれば成功

表示

ビルドに成功すると、以下のように表示されます

=== バトル開始 ===

--- ターン 1 ---
? 勇者アリス のターン:攻撃!
スライム は 10 ダメージ!(残 HP: 20)
? スライム のターン:ランダム攻撃!
勇者アリス は 8 ダメージ!(残 HP: 32)

--- ターン 2 ---
? 勇者アリス のターン:攻撃!
スライム は 10 ダメージ!(残 HP: 10)
? スライム のターン:ランダム攻撃!
勇者アリス は 9 ダメージ!(残 HP: 23)

--- ターン 3 ---
? 勇者アリス のターン:攻撃!
スライム は 10 ダメージ!(残 HP: 0)

=== バトル終了 ===
勇者アリス : 勝利
スライム : 敗北

Enter で終了…

7. 発展課題(自由研究)

レベルアイデア実装ヒント
★☆☆シンプルに複数の敵を出してみましょうnew Enemy(“スライムB") などを Battle に追加
★★☆スキル追加ISkill インターフェースと Character.Act 内のランダム発動
★★★Windows Forms 版ログを ListBox、操作を Button で実装し、モデル層はそのまま再利用

以下では 元のコンソール版コード(約 200 行・.NET Framework 4.8) を前提に、記事 7 章「発展課題(自由研究)」の サンプル解答 を 3 レベル分まとめて示します。

“差分中心” で示していますので、そのまま貼り付けて動作確認できます。


★☆☆ シンプルに複数の敵を出してみましょう

変更点

  1. Program.cs で Battle に渡す配列を 1 体 → 2 体へ増やします。
  2. Character 側は変更不要です(既に foreach (var actor in _actors.Where(a => a.IsAlive)) で全キャラを回しているため)。
// Program.cs ─ Main メソッドだけ差し替え
static void Main()
{
    var actors = new Character[]
    {
        new Player("勇者",   hp: 30, atk: 8),
        new Enemy ("スライムA", hp: 12, atk: 4),
        new Enemy ("スライムB", hp: 10, atk: 3)   // ★ 追加
    };

    var battle = new Battle(actors);
    battle.Run();
}
  1. Player.cs / Enemy.csでオブション引数を受け取るようにコンストラクタのみ変更します
    この場合、hpとatkが省略されていても規定値が使われます
public class Player : Character
{
    public Player(string name, int hp = 40, int atk = 10) : base(name, hp, atk) { }

}

public class Enemy : Character
{
    public Player(string name, int hp = 12, int atk = 4) : base(name, hp, atk) { }
}

このようにすれば、new Player(“勇者", 30, 8) や new Enemy(“スライムB", 10, 3) として渡せるようになります。

ポイント

  • 乱数は Enemy の static Random Rng を全敵で共有していますので、2 体になってもシード衝突の心配はありません。
  • 体力や攻撃力を変えてみると、基礎パラメータ調整の体験にもなります。

★★☆ スキル追加

1. ISkill インターフェース

public interface ISkill
{
    string Name { get; }
    void Execute(Character user, Character target, Action<string> log);
    int Weight { get; }    // 発動確率ウェイト(高いほど選ばれやすい)
}

2. 例:Fireball スキル

public sealed class Fireball : ISkill
{
    private static readonly Random Rng = new Random();
    public string Name   => "ファイアボール";
    public int    Weight => 3;          // BasicAttack の 3 倍重み

    public void Execute(Character user, Character target, Action<string> log)
    {
        int dmg = user.Atk * 2 + Rng.Next(-1, 2);   // 強め+ばらつき
        target.TakeDamage(dmg);
        log($"{user.Name} は {target.Name} に {Name} を放ち {dmg} ダメージ!");
    }
}

3. Character クラスの拡張

public abstract class Character
{
    // 追加プロパティ
    public List<ISkill> Skills { get; } = new List<ISkill>();

    // Act() を改修(共通処理:Player / Enemyでオーバーライド可)
    public virtual void Act(IEnumerable<Character> actors, Action<string> log)
    {
        var target = actors.First(a => a != this && a.IsAlive);

        // ★ 30% の確率でスキルを発動
        bool useSkill = Skills.Any() && Enemy.Rng.Next(100) < 30;
        if (useSkill)
        {
            ISkill skill = GetRandomSkillByWeight();
            skill.Execute(this, target, log);
        }
        else
        {
            BasicAttack(target, log);
        }
    }

    private ISkill GetRandomSkillByWeight()
    {
        int total = Skills.Sum(s => s.Weight);
        int roll  = Enemy.Rng.Next(total);
        foreach (var s in Skills)
        {
            if ((roll -= s.Weight) < 0) return s;
        }
        return Skills[0];
    }
}

4. スキルを登録

// Program.cs 抜粋
var hero   = new Player("勇者", 30, 8);
var slimeA = new Enemy ("スライムA", 12, 4);
var slimeB = new Enemy ("スライムB", 10, 3)
{
    // スライムB だけファイアボールを習得
    Skills = { new Fireball() }
};

var battle = new Battle(new Character[] { hero, slimeA, slimeB },
                        log: Console.WriteLine);
battle.Run();

学習効果

  • インターフェースで振る舞いを差し替える体験(ポリモーフィズム再確認)
  • List<ISkill> を コンポジションとして持たせ、動的に拡張可能にしています。
  • 重み付きランダム選択で、確率ロジックの基礎も学べます。

★★★ Windows Forms 版(UI フロント分離)

前提:フォーム Designer で下記コントロールを貼るだけです。

  • btnNextTurn(Button, Text=「▶ 次のターン」)
  • lstLog(ListBox, Dock=Fill)

1. MainForm.cs

public partial class MainForm : Form
{
    private readonly Battle _battle;

    public MainForm()
    {
        InitializeComponent();

        // モデルはそのまま再利用
        _battle = new Battle(
            new Character[]
            {
                new Player("勇者", 30, 8),
                new Enemy ("スライムA", 12, 4),
                new Enemy ("スライムB", 10, 3) { Skills = { new Fireball() } }
            },
            log: AddLog);                // デリゲートで UI へ流す
    }

    private void AddLog(string msg)
    {
        lstLog.Items.Add(msg);
        lstLog.TopIndex = lstLog.Items.Count - 1;   // 自動スクロール
    }

    private void btnNextTurn_Click(object sender, EventArgs e)
    {
        if (_battle.IsFinished)
        {
            MessageBox.Show("バトルは終了しました!");
            return;
        }

        _battle.ExecuteTurn();          // 1 ターンだけ進行
        if (_battle.IsFinished)
        {
            AddLog("=== Battle Finished ===");
            btnNextTurn.Enabled = false;
        }
    }
}

2. Battle クラスに 2 つだけ追加

public bool IsFinished => _actors.Count(a => a.IsAlive) <= 1;

public void ExecuteTurn()
{
    _turn++;
    _log?.Invoke($"\n--- Turn {_turn} ---");

    foreach (var actor in _actors.Where(a => a.IsAlive))
        actor.Act(_actors, _log);
}

UI 分離のコツ

  • Battle は UI に一切依存しないまま Action<string> log でメッセージを流し込むだけに留めます。
  • WinForms 側は「1 ターンごとに ExecuteTurn → ListBox に追加」するだけで、モデル層の再利用を体験できます。
  • ボタン連打でデバッグ/テストがしやすく、初心者にも UI/ロジック分離の価値が伝わります。

まとめ

レベル主要トピック学べる設計要素
★☆☆複数敵既存ループを活かした スケールアウト
★★☆ISkill・ランダム発動インターフェース・コンポジション・重み付き確率
★★★WinForms UI 分離3 層風(UI / Application / Domain)の基本

これらを段階的に実装していくことで、初学者でも 「動かしながら OOP を拡張する楽しさ」 を体験できます。


8. まとめ

  • 約 200 行・8 ファイルでターン制バトルの基盤が完成します
  • 継承・ポリモーフィズム・コンポジションをシンプルに体験できます
  • コンソール版でロジックを固めたら、UI や機能を段階的に拡張して設計力を磨けます

このチュートリアルを土台に、ぜひ自分だけのバトル RPG へ育ててみてください。

1. シーケンス図 ― 1 ターンの流れ

  • 実線矢印 … 呼び出し
  • activate / deactivate … メソッドの実行区間

2. アクティビティ図 ― Battle.Run() のロジック

  • この図は while (_actors.All(a => a.IsAlive)) ループとターン増分処理を視覚化しています

3. ステートマシン図 ― Character の生存状態

  • Alive … HP > 0
  • Dead … HP == 0。以降 IsAlive が false となり、Battle.ExecuteTurn() の Where(a => a.IsAlive) フィルタでループ対象外になります。

制作したアプリを元に設計の流れを知る

制作したアプリのメモリ配置について知る(継承)

企画・設計する流れ

Battleクラスは静的クラスにすべき?

結論から言うと Battle クラスを「静的クラス (static class)」にするのはおすすめしません

理由は「バトルごとに状態を持つ必要がある」からです。静的クラスはインスタンスを生成できないため、状態を保持できず “1 回限り” のユーティリティ関数置き場のような役割に限定されてしまいます。


静的クラスとインスタンス クラスの役割の違い

比較項目インスタンス クラス静的クラス (static class)
状態 (フィールド)インスタンスごとに独立して保持共有しかできない(=事実上保持しづらい)
複数バトルの同時進行可能(インスタンスを複数生成)不可(1 つのグローバル状態になる)
テスト容易性DI(依存性注入)やモックがしやすいグローバル依存になるため困難
再利用性 / 拡張性派生クラスやインターフェースで差し替え可継承不可、インターフェース実装不可
用途ゲームの「1 回のバトル」を表現Math クラスのような“純粋な計算集”など

なぜ Battle はインスタンス クラスが適切か

  1. 状態を持つ
    • ターン数、ログ、敵味方の HP など、バトルごとに異なる値を保持するため。
  2. 複数のバトルを管理できる
    • 例えば “連戦モード” や “テストで並列にバトルを走らせる” 場面で支障がない。
  3. 拡張/テストがしやすい
    • 「IBattle インターフェース → Battle 実装 → MockBattle」で単体テスト可能。
  4. SOLID 原則に沿う
    • 単一責任の原則:バトルのロジックだけを担当し、生成や記録は別クラスに委譲できる。

もし静的にしたいと感じる動機と対策

動機本当の解決策
インスタンス生成が面倒new が 1 行だけなら負担は小さい。それでも冗長と感じるなら Factory メソッドBattle.Create(player, enemy) を用意する
状態を持たない計算ロジックだけ欲しいその部分だけ BattleHelper や DamageCalculator など別の静的クラスに分離する
シングルトンに近い設計にしたいBattle をシングルトンにすると「同時に 1 バトルしか出来ない」制約が生まれる。将来の拡張を考えると避けるべき

代替実装例

今のままインスタンス クラス

public class Battle
{
    private readonly Character _player;
    private readonly Character _enemy;
    private int _turn = 1;

    public Battle(Character player, Character enemy)
    {
        _player = player;
        _enemy  = enemy;
    }

    public void Run()
    {
        while (_player.IsAlive && _enemy.IsAlive)
        {
            Console.WriteLine($"\n--- Turn {_turn} ---");
            _player.Act(_enemy);
            if (_enemy.IsAlive) _enemy.Act(_player);
            _turn++;
        }
        Console.WriteLine(_player.IsAlive ? "You Win!" : "You Lose...");
    }
}

「純粋ロジックだけ」静的クラスに抽出

public static class DamageCalculator
{
    public static int CalcPhysicalDamage(int attack, int defense)
        => Math.Max(1, attack - defense);
}

Battle は上記の DamageCalculator を呼び出すだけにしておくと、

状態 と 純粋関数 が分離され、コードの見通しが良くなります。


まとめ

  • 静的クラスは「状態を持たないユーティリティ集」に限定して使う
  • バトルのように“毎回異なる状態が必要な概念”はインスタンス クラスで表現する
  • 設計の判断基準は「状態と責務」に注目するとぶれにくい

これらを踏まえ、Battle クラスは現状の インスタンス クラス のまま維持し、

もし共通計算だけ静的化したい場合は 補助クラス に切り出すのが王道です。

訪問数 48 回, 今日の訪問数 1回