ドラクエ風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. 実際の流れ

  1. プレイヤーが「攻撃」ボタンを押す→ PlayerAttack() が実行される
  2. 敵のHPが減る→ 最後に StateChanged?.Invoke(); が呼ばれる
  3. 「合図(イベント)」が飛ぶ
  4. 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出力を担います。構造的な理解に適しています。

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