ターン制バトル RPG ― .NET Framework 4.8 制作チュートリアル
プログラミング学習を一歩先へ――本記事では .NET Framework 4.8 と Visual Studio 2022 を用い、コンソール上で動く最小構成の ターン制バトル RPG を組み立てながら、オブジェクト指向の核心となる「カプセル化・継承・ポリモーフィズム・コンポジション」を体験的に学びます。対象は C# の基礎構文を習得したばかりの初学者。単なるサンプルコードの羅列ではなく、フォルダー構成・クラス設計・LINQ を使ったコードリーディングの勘所まで、実際の開発フローに則って解説していきます。
「手を動かしながら学ぶ」をコンセプトに、プロジェクト作成からビルド&実行、さらには発展課題までを段階的に示しているため、記事を読み進めるだけで 小さくても動くゲーム を完成させることができます。エラーと付き合いながら理解を深める“現場感覚”を味わいたい方は、ぜひ Visual Studio を立ち上げ、本記事を片手にキーボードを叩いてみてください。

- 1. チュートリアル
- 1.1. 0. 目的と前提
- 1.2. 1. プロジェクトを作成する
- 1.3. 2. フォルダーを用意する
- 1.4. 3. モデル層を実装する(Models)
- 1.5. 4. サービス層を実装する(Services)
- 1.6. を 1 行ずつ“分解”すると
- 1.7. ✔ キーアイデア
- 1.8. 5. エントリーポイント(Program.cs)
- 1.9. 6. ビルド & 実行
- 1.10. 7. 発展課題(自由研究)
- 1.11. 8. まとめ
- 1.12. 1. シーケンス図 ― 1 ターンの流れ
- 1.13. 2. アクティビティ図 ― Battle.Run() のロジック
- 1.14. 3. ステートマシン図 ― Character の生存状態
- 2. この内容で設計の流れを知る
チュートリアル
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. フォルダーを用意する
フォルダーやクラスを追加するときは、左ペインの “ソリューション エクスプローラー” でソリューション ノード ではなく “プロジェクト ノード” を右クリック します。
- プロジェクト TurnBasedRpgFw48 を右クリック → 追加 ▸ 新しいフォルダー
- Models
- Services
- Models を右クリック → 追加 ▸ クラス
- Character.cs
- Player.cs
- Enemy.cs
- 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. フロー全体
- _actors.Where(a => a.IsAlive)
- 遅延実行の IEnumerable<Character> が返るだけ。まだ走査されていない。
- foreach が始まると 列挙がスタート
- MoveNext() が呼ばれるたびに 1 要素取り出し
- 条件を満たす(IsAlive == true)要素だけがループ本体へ渡る
- ループ変数 actor にその Character インスタンスが入る
- ループ本体で 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. フロー全体
- _actors 内部を 先頭から順に評価
- 条件 (a != actor && a.IsAlive) を満たす 1 件目を発見した瞬間に返す
- 変数 target にその Character が入る
- 該当要素が無ければ 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. 実際の動作フロー
- 遅延列挙の生成
IEnumerable<Character> alive = _actors.Where(a => a.IsAlive);
- ここではまだ走査されず、“条件だけ覚えたパイプ” が返る。
- foreach 開始で列挙がスタート
- MoveNext() が呼ばれるたび _actors の次要素を取り出し、ラムダ条件を評価。
- IsAlive == false の要素はスキップされ、条件を満たす最初の要素だけが Current として actor へ渡る。
- ループ本体で actor.Act(target); などの処理を実行。
- 以後 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. ビルド & 実行
- ビルド ▸ ソリューションのビルド
- Ctrl + F5(デバッグなし開始)
- コンソールにバトルログが流れ、勝敗が表示されれば成功
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) フィルタでループ対象外になります。
ディスカッション
コメント一覧
まだ、コメントがありません