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

プログラミング学習を一歩先へ――本記事では .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

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);
        }
    }
}

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);
        }
    }
}

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 でターン打ち切り
            }
        }
    }
}

 を 1 行ずつ“分解”すると

部分何をしているか重要ポイント
foreach (列挙処理の開始。IEnumerable<T> を 1 要素ずつ取り出してループを回す C# の構文背景では GetEnumerator() → MoveNext() → Current が呼ばれる
var actor取り出した 1 要素を受け取る ループ変数var は暗黙の型推論。ここでは _actors が List<Character> なので推論結果は Character
inループ対象コレクションを指定
_actorsキャラクターを保持するリスト(List<Character>)もとのクラス設計で Battle が保持しているフィールド
.Where(LINQ 拡張メソッド。要素を条件でフィルタリングするSystem.Linq 名前空間にある
a => a.IsAliveラムダ式。各 a(=Character)について IsAliveが true なら通すFunc<Character,bool> 型のデリゲート
)Where の呼び出し終了
)(foreachの閉じ)ループ構文終了

1. フロー全体

  1. _actors.Where(a => a.IsAlive)
    • 遅延実行の IEnumerable<Character> が返るだけ。まだ走査されていない。
  2. foreach が始まると 列挙がスタート
    • MoveNext() が呼ばれるたびに 1 要素取り出し
    • 条件を満たす(IsAlive == true)要素だけがループ本体へ渡る
  3. ループ変数 actor にその Character インスタンスが入る
  4. ループ本体で actor.Act(target) などを実行

2. “LINQ あり” と “LINQ なし” の等価コード

// LINQ 版(本コード)
foreach (var actor in _actors.Where(a => a.IsAlive))
{
    ...
}

// 伝統的な if フィルター版
foreach (Character actor in _actors)
{
    if (!actor.IsAlive) continue;  // 死んでいるキャラはスキップ
    ...
}
  • 可読性: LINQ 版は「生きているキャラだけ回す」と宣言的で読みやすい
  • コスト: .Where は基本的にO(n) で 1 回だけ走査(遅延実行ゆえ余計な配列コピーなし)

3. .Whereが「遅延実行」である影響

  • ループ途中で actor.IsAlive を false に変更しても、すでにイテレーションに渡された要素は止まりません
  • ただし 次に MoveNext() した時点では最新状態が評価される
foreach (var actor in _actors.Where(a => a.IsAlive))
{
    actor.TakeDamage(999); // ここで死亡判定に変わる
    // すでに Current なので今ターンの処理は続行
}
  • 副作用が多い場合は、先に ToList() でコピーしてから
foreach (var actor in _actors.Where(a => a.IsAlive).ToList()) { ... }
  • として「確定集合」にしておく手もある

4. var を明示型にしたいとき

foreach (Character actor in _actors.Where(a => a.IsAlive))
  • リファクタリング時の安心感が欲しいなら明示型を使う
  • ただし LINQ 連鎖が長くなると推論型を調べる手間が増えるため、初学者は var ベース+マウスホバーで型確認がおすすめ

5. パフォーマンス・注意点まとめ

観点コメント
速度1 回走査のみ。foreach + if と同等の O(n)
メモリ遅延実行なので中間リストは生成されない
可読性「生きているキャラだけ」と 意図が先に読める
ループ中に _actors を追加・削除すると InvalidOperationException になる

✔ キーアイデア

「コレクション → フィルタ → 列挙」を 1 行で連結できるのが LINQ

  • 条件をラムダで書くことで if 文を外に追い出せる
  • 遅延実行により余計なリスト生成を避けつつ、宣言的に意図を表現

この 1 行を理解すれば、LINQ パイプライン(Where ➜ Select ➜ OrderBy など)が「読みやすく、高速で、メモリ効率も良い」理由が腑に落ちるはずです。

切片していること補足ポイント
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 で

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

断片役割詳細・ポイント
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 行 で記述
  • 遅延実行で無駄なメモリ割当なし
  • コードを「意図 → 処理」の順に読める

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


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();
        }
    }
}

6. ビルド & 実行

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

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

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

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) フィルタでループ対象外になります。

この内容で設計の流れを知る

訪問数 5 回, 今日の訪問数 5回