ドラクエ風WinFormsをクラス分けで整理する(4人開発・完成チュートリアル)
この教材は、単一フォームから始めて、GameController → Model/Service分割 → インターフェイス注入 → 4人体制まで段階的に発展させます。各段階は「なぜやるか」を明確化し、最小で動くコードを提示します。
このチュートリアルは、WinFormsで「クラス分けしないバトル」アプリを作ろうを終えた方向けに構成されています
Lv1〜Lv5(要約)
- Lv1:単一Form → イベントと状態管理をFormに直書き
- Lv2:GameController導入 → ロジックを切り分け、Formをすっきり
- Lv3:モデル&サービス分割 → OOPの基礎(継承・サービスクラス)
- Lv4:ログを抽象化 → インターフェイス導入で差し替え可能設計
- Lv5:4人開発想定 → リーダーも含む現実的な役割分担とGit運用
Lv1:単一Form版(まず動かす)
ねらい
- WinFormsのイベントと状態管理の基礎をつかむ
- UIとロジックが混ざっていてもOK。まずは成功体験
Visual Studio 初期設定(具体例)
- 新しいプロジェクト → Windows Forms App (.NET)(言語: C#)
- 入力値(今回の教材前提)
- ソリューション名: RpgSample
- プロジェクト名: RpgSample
- 保存先: C:\Users\<あなたのユーザー名>\Desktop\CSharpProjects\RpgSample
- 作成後に生成される主なファイル
- RpgSample.sln, RpgSample\RpgSample.csproj, Form1.cs, Form1.Designer.cs, Program.cs
置くコントロール
btnAttack, btnHeal, lblPlayerHp, lblEnemyHp, txtLog(TextBoxは複数行・読み取り専用・縦スクロール)
最小コード(Form1.cs)
using System;
using System.Windows.Forms;
namespace RpgSample
{
public partial class Form1 : Form
{
int playerHp = 50, playerMax = 50;
int enemyHp = 35, enemyMax = 35;
public Form1()
{
InitializeComponent();
UpdateUi();
}
private void btnAttack_Click(object sender, EventArgs e)
{
int dmg = 8; // とりあえず固定
enemyHp = Math.Max(0, enemyHp - dmg);
txtLog.AppendText($"プレイヤーの攻撃! {dmg} ダメージ{Environment.NewLine}");
if (enemyHp > 0) EnemyTurn();
UpdateUi();
}
private void btnHeal_Click(object sender, EventArgs e)
{
int heal = 8;
int before = playerHp;
playerHp = Math.Min(playerMax, playerHp + heal);
txtLog.AppendText($"プレイヤーは {playerHp - before} 回復した{Environment.NewLine}");
EnemyTurn();
UpdateUi();
}
private void EnemyTurn()
{
int dmg = 6;
playerHp = Math.Max(0, playerHp - dmg);
txtLog.AppendText($"スライムの攻撃! {dmg} ダメージ{Environment.NewLine}");
}
private void UpdateUi()
{
lblPlayerHp.Text = $"{playerHp}/{playerMax}";
lblEnemyHp.Text = $"{enemyHp}/{enemyMax}";
bool live = playerHp > 0 && enemyHp > 0;
btnAttack.Enabled = btnHeal.Enabled = live;
}
}
}
確認
- 攻撃/回復ボタンでHPが変化し、ログが流れる
Lv2:GameController導入(ロジック分離)
ねらい
- UI(表示)と進行(処理)を分けて見通しを良くする
- まずはクラス1つに切り出すだけ
追加ファイル:GameController.cs
using System;
namespace RpgSample
{
public sealed class GameController
{
public int PlayerHp { get; private set; } = 50;
public int PlayerMax { get; } = 50;
public int EnemyHp { get; private set; } = 35;
public int EnemyMax { get; } = 35;
public event Action? StateChanged;
public Action<string>? Log; // 簡易ロガー
public void PlayerAttack()
{
EnemyHp = Math.Max(0, EnemyHp - 8);
Log?.Invoke("プレイヤーの攻撃! 8 ダメージ");
AfterPlayerAction();
}
public void PlayerHeal()
{
int before = PlayerHp;
PlayerHp = Math.Min(PlayerMax, PlayerHp + 8);
Log?.Invoke($"プレイヤーは {PlayerHp - before} 回復した");
AfterPlayerAction();
}
private void AfterPlayerAction()
{
if (PlayerHp > 0 && EnemyHp > 0) EnemyTurn();
StateChanged?.Invoke();
}
private void EnemyTurn()
{
PlayerHp = Math.Max(0, PlayerHp - 6);
Log?.Invoke("スライムの攻撃! 6 ダメージ");
}
}
}
1. イベントってなに?
- 「合図」みたいなものです。
- あるクラスの中で「こういうことが起きたよ!」と外に知らせる仕組み。
- それを聞いている人(別のクラスやフォーム)が「合図を受け取ったら、この処理をする」と決められます。
2. 今回のコードでの例
GameController の中
public event Action? StateChanged;
- 「プレイヤーや敵のHPが変わったよ!」と知らせるための合図。
- 戦闘が進むと、StateChanged?.Invoke(); が呼ばれて、「はい、変わりましたよー!」と外に通知します。
Form1 の中
_game.StateChanged += UpdateUi;
- 「合図が来たら UpdateUi(画面のHP表示を更新する)を実行する」と登録している。
- つまり += は「イベントが起きたらこのメソッドを呼んでね」と教えること。
3. 実際の流れ
- プレイヤーが「攻撃」ボタンを押す→ PlayerAttack() が実行される
- 敵のHPが減る→ 最後に StateChanged?.Invoke(); が呼ばれる
- 「合図(イベント)」が飛ぶ
- Form1 の UpdateUi() が実行される→ HPラベルが更新される
4. イメージでいうと
- GameController = 戦闘の司令官
- イベント = 「おーい!HP変わったぞ!」という叫び声
- Form1 = それを聞いて「はい!画面を書き換えます!」と動く人
まとめ
- イベントは「通知の仕組み」
- Invoke で通知を出す
- += で「通知を受け取ったら何をするか」を登録する
- 登録されたメソッドが自動的に呼ばれる
「イベントは ドアベル と同じ。押されたら音が鳴る。押す人と鳴らす仕組みを分けて書ける」
と例えるのも良いですよ。
イベントは「呼び鈴」
図イメージ
[GameController]───(呼び鈴: StateChanged)───>[Form1.UpdateUi]
・HPや状態が変わると「呼び鈴」を鳴らす
・呼び鈴が鳴ったら、外の誰かが応答する
ポイント
- event は「中で何かが起きた!」を外に通知する仕組み
- Action は「引数なし・戻り値なしのメソッド」の型
- StateChanged は「HPが変わったときに鳴る呼び鈴」
コードで見るイベント
宣言する側(GameController.cs)
public event Action? StateChanged; // 呼び鈴
private void AfterPlayerAction()
{
// 状態が変わったので呼び鈴を鳴らす
StateChanged?.Invoke();
}
登録する側(Form1.cs)
public Form1()
{
InitializeComponent();
_game = new GameController(...);
// 「呼び鈴が鳴ったらこのメソッドを実行してね」
_game.StateChanged += UpdateUi;
}
private void UpdateUi()
{
lblPlayerHp.Text = $"{_game.Player.HP}/{_game.Player.MaxHP}";
lblEnemyHp.Text = $"{_game.Enemy.HP}/{_game.Enemy.MaxHP}";
}
初学者向けまとめ
- event = 呼び鈴(通知の仕組み)
- Action = 呼び鈴が鳴ったら実行される処理の型
- += メソッド; = 呼び鈴に応答する人を登録
- ?.Invoke(); = 呼び鈴を鳴らす(登録がなければ何も起きない)
Form1.cs(呼び出すだけに簡略化)
public partial class Form1 : Form
{
private GameController _game = new();
public Form1()
{
InitializeComponent();
_game.Log = msg => txtLog.AppendText(msg + Environment.NewLine);
_game.StateChanged += UpdateUi;
UpdateUi();
}
private void btnAttack_Click(object s, EventArgs e) => _game.PlayerAttack();
private void btnHeal_Click(object s, EventArgs e) => _game.PlayerHeal();
private void UpdateUi()
{
lblPlayerHp.Text = $"{_game.PlayerHp}/{_game.PlayerMax}";
lblEnemyHp.Text = $"{_game.EnemyHp}/{_game.EnemyMax}";
bool live = _game.PlayerHp > 0 && _game.EnemyHp > 0;
btnAttack.Enabled = btnHeal.Enabled = live;
}
}
確認
- 挙動はLv1と同じ。Form1のコード量が大幅減
Lv3:モデル&サービス分割(OOPの骨格)
ねらい
- 共通のCharacter基底クラス→Player/Enemy派生
- 戦闘処理はBattleServiceへ
Models/Character.cs
namespace RpgSample.Models
{
public abstract class Character
{
public string Name { get; }
public int MaxHP { get; }
public int HP { get; private set; }
public int Attack { get; }
protected Character(string name, int maxHp, int attack)
{
Name = name; MaxHP = maxHp; HP = maxHp; Attack = attack;
}
public void Heal(int amount) => HP = Math.Min(MaxHP, HP + Math.Max(0, amount));
public void TakeDamage(int d) => HP = Math.Max(0, HP - Math.Max(0, d));
public bool IsDead => HP <= 0;
}
}
Models/Player.cs
namespace RpgSample.Models
{
public sealed class Player : Character
{
public Player(string name, int maxHp, int attack) : base(name, maxHp, attack) {}
}
}
Models/Enemy.cs
namespace RpgSample.Models
{
public sealed class Enemy : Character
{
public double CriticalRate { get; }
public Enemy(string name, int maxHp, int attack, double criticalRate = 0.2)
: base(name, maxHp, attack)
{
CriticalRate = Math.Clamp(criticalRate, 0.0, 1.0);
}
}
}
Services/BattleService.cs
using RpgSample.Models;
namespace RpgSample.Services
{
public sealed class BattleService
{
private readonly Random _rand = new();
public (int damage, bool critical) Attack(Character attacker, Character defender)
{
int baseDmg = Math.Max(1, attacker.Attack + _rand.Next(-2, 3)); // ±2ゆらぎ
bool crit = attacker is Enemy e && _rand.NextDouble() < e.CriticalRate;
int dmg = crit ? (int)(baseDmg * 1.5) : baseDmg;
defender.TakeDamage(dmg);
return (dmg, crit);
}
}
}
GameController.cs(OOP構成へ)
using RpgSample.Models;
using RpgSample.Services;
namespace RpgSample
{
public sealed class GameController
{
public Player Player { get; }
public Enemy Enemy { get; }
private readonly BattleService _battle = new();
public event Action? StateChanged;
public Action<string>? Log;
public GameController()
{
Player = new Player("勇者", 50, 10);
Enemy = new Enemy("スライム", 35, 7, 0.2);
}
public void PlayerAttack()
{
var (d, c) = _battle.Attack(Player, Enemy);
Log?.Invoke($"プレイヤーの攻撃!{(c ? "会心の一撃! " : "")}{d} ダメージ");
After();
}
public void PlayerHeal(int amount = 8)
{
int before = Player.HP;
Player.Heal(amount);
Log?.Invoke($"プレイヤーは {Player.HP - before} 回復した");
After();
}
private void EnemyTurn()
{
var (d, c) = _battle.Attack(Enemy, Player);
Log?.Invoke($"スライムの攻撃!{(c ? "痛恨の一撃! " : "")}{d} ダメージ");
}
private void After()
{
if (!Player.IsDead && !Enemy.IsDead) EnemyTurn();
StateChanged?.Invoke();
}
}
}
Form1.cs(表示はそのまま)
private void UpdateUi()
{
lblPlayerHp.Text = $"{_game.Player.HP}/{_game.Player.MaxHP}";
lblEnemyHp.Text = $"{_game.Enemy.HP}/{_game.Enemy.MaxHP}";
bool live = !_game.Player.IsDead && !_game.Enemy.IsDead;
btnAttack.Enabled = btnHeal.Enabled = live;
}
確認
- 構造が「Controller→Service→Model」に整理され、拡張しやすい
Lv4:ログを抽象化(IBattleLog注入)
ねらい
- 出力先の差し替え(TextBox→ファイル等)が可能に
- インターフェイスでUI依存を切る
Services/IBattleLog.cs
namespace RpgSample.Services
{
public interface IBattleLog
{
void WriteLine(string message);
}
}
Services/TextBoxBattleLog.cs
namespace RpgSample.Services
{
public sealed class TextBoxBattleLog : IBattleLog
{
private readonly TextBox _textBox;
public TextBoxBattleLog(TextBox textBox)
{
_textBox = textBox;
_textBox.ReadOnly = true;
_textBox.Multiline = true;
_textBox.ScrollBars = ScrollBars.Vertical;
}
public void WriteLine(string message) =>
_textBox.AppendText(message + Environment.NewLine);
}
}
GameController.cs(依存を注入)
using RpgSample.Models;
using RpgSample.Services;
namespace RpgSample
{
public sealed class GameController
{
public Player Player { get; }
public Enemy Enemy { get; }
private readonly BattleService _battle;
private readonly IBattleLog _log;
public event Action? StateChanged;
public GameController(Player p, Enemy e, IBattleLog log, BattleService battle)
{
Player = p; Enemy = e; _log = log; _battle = battle;
}
public void PlayerAttack()
{
var (d, c) = _battle.Attack(Player, Enemy);
_log.WriteLine($"プレイヤーの攻撃!{(c ? "会心の一撃! " : "")}{d} ダメージ");
After();
}
public void PlayerHeal(int amount = 8)
{
int before = Player.HP;
Player.Heal(amount);
_log.WriteLine($"プレイヤーは {Player.HP - before} 回復した");
After();
}
private void EnemyTurn()
{
var (d, c) = _battle.Attack(Enemy, Player);
_log.WriteLine($"スライムの攻撃!{(c ? "痛恨の一撃! " : "")}{d} ダメージ");
}
private void After()
{
if (!Player.IsDead && !Enemy.IsDead) EnemyTurn();
StateChanged?.Invoke();
}
}
}
Form1.cs(最終の組み立て)
using RpgSample.Models;
using RpgSample.Services;
namespace RpgSample
{
public partial class Form1 : Form
{
private GameController _game = null!;
private TextBoxBattleLog _log = null!;
public Form1()
{
InitializeComponent();
_log = new TextBoxBattleLog(txtLog);
var player = new Player("勇者", 50, 10);
var slime = new Enemy("スライム", 35, 7, 0.2);
var battle = new BattleService();
_game = new GameController(player, slime, _log, battle);
_game.StateChanged += UpdateUi;
UpdateUi();
}
private void btnAttack_Click(object s, EventArgs e) => _game.PlayerAttack();
private void btnHeal_Click(object s, EventArgs e) => _game.PlayerHeal();
private void UpdateUi()
{
lblPlayerHp.Text = $"{_game.Player.HP}/{_game.Player.MaxHP}";
lblEnemyHp.Text = $"{_game.Enemy.HP}/{_game.Enemy.MaxHP}";
bool live = !_game.Player.IsDead && !_game.Enemy.IsDead;
btnAttack.Enabled = btnHeal.Enabled = live;
}
}
}
確認
- ログ実装を差し替え可能に(例:FileBattleLogを作れば保存できる)
Lv5:4人開発(リーダー含む)
ねらい
- 現実の演習に近い分担とGit運用を練習
- リーダーも実装担当としてカウント
割り当て
- リーダー:Form1・GameController(UI・進行・統合・レビュー)
- メンバーA:Models(Character/Player/Enemy)
- メンバーB:BattleService(戦闘ロジック)
- メンバーC:IBattleLog + TextBoxBattleLog(ログ出力抽象・UI実装)

Git最小フロー
1) リーダー: 新規リポジトリ → フォーム配置&雛形コミット
2) A/B/C: 各自ブランチで担当フォルダを実装
3) PR→リーダーがレビュー&マージ(命名・構成の統一)
4) リーダー: GameControllerで依存を組み立てて動作確認→mainマージ
まとめと次の一歩
- Lv1→Lv4でUIとロジックの分離・OOP・DIの基本を段階的に習得
- Lv5で役割分担とGitフローに触れ、実務感覚を獲得
- 発展:IRandom導入で乱数を差し替え(テスト容易化)/IEnemyActionPolicyで敵AI差し替え
次のステップ
参考
クラス図
この図はアプリの全体的なクラス構造を示しています。Form1はUI担当、GameControllerは進行管理を担い、BattleServiceが戦闘処理を、Player/Enemyがキャラクター情報を扱います。ログ出力はIBattleLogを介して抽象化され、TextBoxBattleLogでUIに表示されます。各クラスの担当者を明示することで、チーム内の役割分担をわかりやすくしています。

コンポーネント図
この図は処理の流れを俯瞰的に表した構造図です。ユーザーが操作するForm1はGameControllerに処理を依頼し、GameControllerがBattleServiceやModel層にアクセスして計算・状態管理を行います。ログの出力はインターフェイスを経由してTextBoxBattleLogに渡され、再びUIに表示されます。依存関係をシンプルに把握できるのが特徴です。

シーケンス図
攻撃ボタンが押された際の処理フローを時系列に示しています。ユーザー操作を受けたForm1はGameControllerのPlayerAttackを呼び、BattleServiceがダメージ計算を行い、対象キャラに反映します。その結果はログ出力を通じてUIに記録され、さらに敵が生きていれば反撃処理も続きます。最後にUI更新イベントが発火し、HPやボタンの状態が更新されます。

回復ボタン押下時の処理フローを示します。Form1からGameControllerにPlayerHealが呼ばれ、PlayerがHPを回復します。その後ログに「回復した」メッセージが書き込まれ、敵が生きていれば反撃ターンに移ります。最後にStateChangedイベントでUIが更新され、ラベルやボタンの状態が反映されます。攻撃フローと対比することで処理共通点と違いを理解しやすくなります。

パッケージ図
Models・Services・UIという3つのフォルダ構成を表し、各ファイルの依存関係を明示した図です。Form1はUI層に属し、ServicesのGameControllerやIBattleLogを利用します。GameControllerはModelsのPlayerやEnemyを参照し、戦闘処理はBattleServiceに委譲します。IBattleLogは実装を切り替え可能で、TextBoxBattleLogが標準的なUI出力を担います。構造的な理解に適しています。

ディスカッション
コメント一覧
まだ、コメントがありません