はじめてでもわかるオブジェクト指向入門(C#/WinForms)—「違いは中に隠す」を体験

対象:C#の基礎とWinFormsの基本(プロジェクト作成/コントロール配置)がわかる初学者

環境例:.NET Framework(4.7.2 〜 4.8 目安)/Visual Studio(Windows)


ねらい

  • 専門用語は後回し。“手で動かす → 便利さを体験 → 名前(用語)を付ける」順で学ぶ。
  • 痛み(コピペ地獄)→解決(クラス化)→さらに解決(継承・ポリモーフィズム)を段階的に体験する。
  • クリック→HP減少→ログ表示の即時フィードバックでモチベーションを切らさない。

画面レイアウト(固定)

種別NameText初期状態位置の目安
ButtonbtnSlimeスライムEnabled = true左上(x:20, y:20)
ButtonbtnGoblinゴブリンEnabled = true左上(x:120, y:20)
ButtonbtnAttack攻撃Enabled = false左上(x:220, y:20)
ListBoxlstLog下部(x:20, y:60, サイズ:幅280×高120)

デザイナで上記のNameTextを正しく設定します。

以降のコードは、デザイナがイベント配線をしていなくても動くよう、コード側でイベントを購読します。

用語の整理(この記事ではこう使う)

  • イベント登録(購読 / subscribe):+= で ハンドラーを登録する行為。解除は -=(購読解除 / unsubscribe)。
  • イベント配線(wire up):UIの要素と処理を結びつける作業をやや比喩的に指す語。デザイナ操作や InitializeComponent の自動コード生成まで含めて言うことが多い。
  • イベント発生(raise / fire):イベントを起こす側の動作。WinFormsでは OnClick(e) などの OnXxx メソッドを呼ぶのが定石。

授業では、「コード上の操作」は登録/解除、「画面とコードを結ぶ全体の作業」は配線と分けて説明すると混乱が減ります。


1) 登録(購読)の基本パターン

デザイナが自動で書く(InitializeComponent内)

this.btnAttack.Click += new System.EventHandler(this.btnAttack_Click);
// 近年のC#ではこの形でも同じ
this.btnAttack.Click += this.btnAttack_Click;

ランタイムで手動登録(推奨の教え方)

btnAttack.Click += BtnAttack_Click;              // 登録
btnAttack.Click -= BtnAttack_Click;              // 解除(重要)
void BtnAttack_Click(object sender, EventArgs e) { /*...*/ }

ラムダを使う場合の注意(解除できるように保持)

EventHandler h = (_, e) => { /* ... */ };
btnAttack.Click += h;
// ...後で外したいとき
btnAttack.Click -= h;   // 同一インスタンスを渡さないと外れない

複数ハンドラー・順序

  • += した順に呼ばれる順が積み上がる(Invocation List)。
  • 同じハンドラーを重複登録すると複数回呼ばれる(初学者がやりがち)。

2) 解除の重要性(“記憶に残す”ポイント)

  • 長寿命の発行元(静的イベント、シングルトン、タイマー)に登録しておき、短寿命オブジェクトが解除しないと解放されずメモリリークの温床に。
  • WinFormsのコントロール同士は概ね同寿命なので深刻化しづらいが、静的イベントに登録したら Dispose / OnHandleDestroyed で外す癖を。

3) 「配線」の実体(デザイナ/コードの役割分担)

  • 配線=UIと処理の橋渡し
    • デザイナ派:プロパティグリッドでイベントをダブルクリック → InitializeComponent に += が生成。
    • コード派(教材向き)コンストラクタで一括登録して見える化(「この画面はここで繋いでいる」と示せる)。

教材では「イベント登録はコードで明示」を推奨。初学者は“どこで繋がっているか”を目で追えることが理解の助けになります。


4) UserControlでの“再公開”の言い方(バブリングとの違い)

  • WinFormsにはWPFのような自動的なバブリング伝播はありません。
  • この記事の MyButton でやっているのは再公開(フォワーディング)
btnCore.Click += (_, e) => OnClick(e); // 内側のClickを受けて、自分のClickを発生させる
  • 正しい言い方
    • 「内側のイベントを再公開する」「転送する」「自分のClickを発生させる」
    • 「バブリング」はWPFの用語なのでWinFormsでは使わない方が安全。

併用時の落とし穴

  • btnCore.Click に直接も、MyButton.Click にも同じ処理を登録すると二重実行になることがある。
  • ルール化:利用側は MyButton.Click だけを使う(内側は隠す)。

5) 安全運用チェックリスト(授業用)

  • 登録は一箇所で明示(Form コンストラクタ推奨)
  • 重複登録していないか
  • ラムダは参照変数に保持して外せるように
  • 長寿命発行元に登録したら解除(Dispose 等)
  • UserControlのイベントは再公開を1本化し、外からは中を直接触らない

6) 用語の言い換えテンプレ(板書向け)

  • 登録=購読する(+=)/解除=購読をやめる(-=)
  • 発生=イベントを起こす(OnXxx(e) を呼ぶ)
  • 配線=UIとコードを結ぶ(デザイナ or コードでセットアップ)
  • 再公開=内側の出来事を外側に伝える(UserControlで OnClick 呼び)

7) サンプルまとめ(最小形)

// 1) 登録(FormやUserControlのコンストラクタで)
btnSlime.Click  += BtnSlime_Click;
btnGoblin.Click += BtnGoblin_Click;
btnAttack.Click += BtnAttack_Click;

// 2) 解除(必要な場面で)
btnAttack.Click -= BtnAttack_Click;

// 3) UserControlでの再公開(WinFormsは“転送”と言う)
btnCore.Click += (_, e) => OnClick(e); // 自分のClickを発生させる
protected override void OnClick(EventArgs e)
{
    // 追加処理があればここに
    base.OnClick(e); // 購読者に通知
}

結論(言葉の選び方)

  • コードの操作は「登録/解除」
  • 画面とコードの結び付け全体は「配線」
  • UserControlで内→外へ出すのは「再公開/転送」。この三つを使い分けると、用語のブレがなくなり、初学者にも伝わりやすくなります。

ステージA:まずは動く(非OOP)

まだ「クラス」「継承」は言いません。2体の敵のHPを個別の変数で持ち、重複感をわざと残します。

Form1.cs(貼り付け可)

using System;
using System.Windows.Forms;

namespace MonsterGame
{
    public partial class Form1 : Form
    {
        private string _target;     // "Slime" or "Goblin"
        private int _slimeHp = 10;
        private int _goblinHp = 12;

        public Form1()
        {
            InitializeComponent();

            // デザイナで配線していなくてもOKにする
            btnSlime.Click += btnSlime_Click;
            btnGoblin.Click += btnGoblin_Click;
            btnAttack.Click += btnAttack_Click;

            btnAttack.Enabled = false; // 敵未選択では攻撃不可
        }

        private void btnSlime_Click(object sender, EventArgs e)
        {
            _target = "Slime";
            btnAttack.Enabled = _slimeHp > 0;
            lstLog.Items.Add("スライムに照準を合わせた");
        }

        private void btnGoblin_Click(object sender, EventArgs e)
        {
            _target = "Goblin";
            btnAttack.Enabled = _goblinHp > 0;
            lstLog.Items.Add("ゴブリンに照準を合わせた");
        }

        private void btnAttack_Click(object sender, EventArgs e)
        {
            if (_target == "Slime")
            {
                _slimeHp = Math.Max(0, _slimeHp - 3);
                lstLog.Items.Add($"スライムに3ダメージ! 残りHP: {_slimeHp}");
                if (_slimeHp == 0)
                {
                    lstLog.Items.Add("スライムを倒した!");
                    btnAttack.Enabled = false;
                }
            }
            else if (_target == "Goblin")
            {
                _goblinHp = Math.Max(0, _goblinHp - 2);
                lstLog.Items.Add($"ゴブリンに2ダメージ! 残りHP: {_goblinHp}");
                if (_goblinHp == 0)
                {
                    lstLog.Items.Add("ゴブリンを倒した!");
                    btnAttack.Enabled = false;
                }
            }
        }
    }
}

この段階の到達点

  • 敵を選ぶ→攻撃→HPが減る→ログが増える。
  • 敵未選択では攻撃できない(btnAttack.Enabled を制御)。
  • ただし、敵ごとに似たコードが2セット。3体・4体に増えるとつらい=痛み

ステージB:“箱”にまとめる(最小のクラス化)

ここで初めて「箱=クラス」を紹介。

名札(Name)とHPを持つ箱にまとめるだけで、変数がスッキリします。

Enemy.cs を追加

namespace MonsterGame
{
    // 名札とHPを持つ「箱」
    class Enemy
    {
        public string Name;
        public int Hp;

        public Enemy(string name, int hp)
        {
            Name = name;
            Hp = hp;
        }

        public void ReceiveFixedDamage(int damage)
        {
            Hp -= damage;
            if (Hp < 0) Hp = 0;
        }

        public bool IsDead => Hp <= 0;
    }
}

Form1.cs(ステージB用)

using System;
using System.Windows.Forms;

namespace MonsterGame
{
    public partial class Form1 : Form
    {
        private Enemy _slime = new Enemy("スライム", 10);
        private Enemy _goblin = new Enemy("ゴブリン", 12);
        private Enemy _target;

        public Form1()
        {
            InitializeComponent();

            btnSlime.Click += btnSlime_Click;
            btnGoblin.Click += btnGoblin_Click;
            btnAttack.Click += btnAttack_Click;

            btnAttack.Enabled = false;
        }

        private void btnSlime_Click(object sender, EventArgs e)
        {
            _target = _slime;
            btnAttack.Enabled = !_target.IsDead;
            lstLog.Items.Add($"{_target.Name}に照準を合わせた");
        }

        private void btnGoblin_Click(object sender, EventArgs e)
        {
            _target = _goblin;
            btnAttack.Enabled = !_target.IsDead;
            lstLog.Items.Add($"{_target.Name}に照準を合わせた");
        }

        private void btnAttack_Click(object sender, EventArgs e)
        {
            if (_target == null) return;

            int damage = (_target == _slime) ? 3 : 2; // まだ分岐は残す
            _target.ReceiveFixedDamage(damage);
            lstLog.Items.Add($"{_target.Name}に{damage}ダメージ! 残りHP: {_target.Hp}");

            if (_target.IsDead)
            {
                lstLog.Items.Add($"{_target.Name}を倒した!");
                btnAttack.Enabled = false;
            }
        }
    }
}

この段階の到達点

  • 変数が整理され、「名札とHPがひとかたまり」という直感が育つ。
  • まだ敵ごとの違い(ダメージ量)は外側の分岐で持っている。

ステージC:“違いは中に隠す”(継承+ポリモーフィズム)

ここで種類ごとの差内側に移す

「スライムは1撃で3減る」「ゴブリンは2減る」をそれぞれの設計図(クラス)に書く

Enemy.cs(ステージC用:最小の継承)

namespace MonsterGame
{
    class Enemy
    {
        public string Name;
        public int Hp;

        public Enemy(string name, int hp)
        {
            Name = name;
            Hp = hp;
        }

        // 種類ごとの差はここで表現(仮想プロパティ)
        public virtual int DamagePerHit => 1;

        public void ReceiveHit()
        {
            Hp -= DamagePerHit;
            if (Hp < 0) Hp = 0;
        }

        public bool IsDead => Hp <= 0;
    }

    class Slime : Enemy
    {
        public Slime() : base("スライム", 10) { }
        public override int DamagePerHit => 3;
    }

    class Goblin : Enemy
    {
        public Goblin() : base("ゴブリン", 12) { }
        public override int DamagePerHit => 2;
    }
}

Form1.cs(ステージC用)

using System;
using System.Windows.Forms;

namespace MonsterGame
{
    public partial class Form1 : Form
    {
        private Enemy _slime = new Slime();
        private Enemy _goblin = new Goblin();
        private Enemy _target;

        public Form1()
        {
            InitializeComponent();

            btnSlime.Click += btnSlime_Click;
            btnGoblin.Click += btnGoblin_Click;
            btnAttack.Click += btnAttack_Click;

            btnAttack.Enabled = false;
        }

        private void btnSlime_Click(object s, EventArgs e)
        {
            _target = _slime;
            btnAttack.Enabled = !_target.IsDead;
            lstLog.Items.Add($"{_target.Name}に照準を合わせた");
        }

        private void btnGoblin_Click(object s, EventArgs e)
        {
            _target = _goblin;
            btnAttack.Enabled = !_target.IsDead;
            lstLog.Items.Add($"{_target.Name}に照準を合わせた");
        }

        private void btnAttack_Click(object s, EventArgs e)
        {
            if (_target == null) return;

            _target.ReceiveHit(); // 種類ごとの差は「中」に隠した
            lstLog.Items.Add($"{_target.Name}に{_target.DamagePerHit}ダメージ! 残りHP: {_target.Hp}");

            if (_target.IsDead)
            {
                lstLog.Items.Add($"{_target.Name}を倒した!");
                btnAttack.Enabled = false;
            }
        }
    }
}

この段階の到達点(ここで初めて用語)

  • カプセル化:HPの更新やダメージ計算をに閉じ込めた。
  • 継承:SlimeとGoblinはEnemyをもとに差分だけを書く。
  • ポリモーフィズム:_targetをEnemy型として同じ手順で扱える。外側の分岐が消える。

口頭チェック(学習確認)

  1. 敵が3体、4体に増えても、btnAttack_Clickに分岐を足さずにすむか。
  2. ダメージ量を変えたいとき、どのファイルを開くか。
  3. 敵を選んでいない時、攻撃できない仕組みはどこにあるか。

よくあるつまずきと対処

  • クリックしても反応しない:btnAttack.Enabled の初期値が true になっていないか/イベントが未配線。→ コンストラクタで btnXxx.Click += … を確認。
  • NullReferenceException:_target が null のまま攻撃した。→ ステージCのコードにある if (_target == null) return; を入れる。
  • 名前の打ち間違い:デザイナの Name とコードの変数名がズレていないか確認。

発展(次の一歩)

  • 敵一覧を並べる:Enemy[] enemies = { new Slime(), new Goblin() }; を用意し、ListBoxに名前を出して選択→攻撃。(Listはまだ学んでいない前提なので配列でOK)
  • クリティカルヒット:Enemyに乱数判定を追加し、ログに“会心の一撃!”を出す。
  • テスト観点:HPが0未満にならないこと/撃破時に攻撃不可になること。

前提:Listは未学習のため配列のみ使用/WinForms(.NET Framework)/ListBoxで敵選択→攻撃。


1) 敵一覧を配列で保持し、ListBoxで選択→攻撃

Enemy.cs(会心判定込み)

using System;

namespace MonsterGame
{
    class Enemy
    {
        private static readonly Random s_rng = new Random();

        public string Name;
        public int Hp;

        // 種類ごとの差はここで表現(仮想プロパティ)
        public virtual int DamagePerHit => 1;

        // 会心(クリティカル)設定(必要なら派生で上書き可)
        protected virtual double CritChance => 0.20; // 20%
        protected virtual int CritMultiplier => 2;   // 倍率

        // 直近の攻撃結果(ログ用)
        public int LastDamage { get; private set; }
        public bool WasCriticalLastHit { get; private set; }

        public Enemy(string name, int hp)
        {
            Name = name;
            Hp = hp;
        }

        public void ReceiveHit()
        {
            int damage = DamagePerHit;

            // 乱数で会心判定
            bool isCrit = s_rng.NextDouble() < CritChance;
            if (isCrit) damage *= CritMultiplier;

            // HPは0未満にしない
            int next = Hp - damage;
            if (next < 0) next = 0;

            // 状態更新
            Hp = next;
            LastDamage = damage;
            WasCriticalLastHit = isCrit;
        }

        public bool IsDead => Hp <= 0;
    }

    class Slime : Enemy
    {
        public Slime() : base("スライム", 10) { }
        public override int DamagePerHit => 3;
        // 例:スライムは会心がやや出やすい等の差も付けられる
        // protected override double CritChance => 0.25;
    }

    class Goblin : Enemy
    {
        public Goblin() : base("ゴブリン", 12) { }
        public override int DamagePerHit => 2;
    }
}

Form1.cs(配列+ListBox選択でターゲットを決定)

using System;
using System.Diagnostics;
using System.Windows.Forms;

namespace MonsterGame
{
    public partial class Form1 : Form
    {
        // 配列で管理(List未使用)
        private Enemy[] _enemies = { new Slime(), new Goblin() };
        private Enemy _target; // ListBoxで選ばれた敵

        public Form1()
        {
            InitializeComponent();

            // 画面イベント配線
            lstEnemies.SelectedIndexChanged += lstEnemies_SelectedIndexChanged;
            btnAttack.Click += btnAttack_Click;

            // 初期化:敵名をListBoxに並べる
            for (int i = 0; i < _enemies.Length; i++)
            {
                lstEnemies.Items.Add(_enemies[i].Name);
            }

            btnAttack.Enabled = false; // 未選択時は攻撃不可
        }

        private void lstEnemies_SelectedIndexChanged(object sender, EventArgs e)
        {
            int index = lstEnemies.SelectedIndex;
            if (index < 0)
            {
                _target = null;
                btnAttack.Enabled = false;
                return;
            }

            _target = _enemies[index];
            btnAttack.Enabled = !_target.IsDead;

            lstLog.Items.Add($"{_target.Name}に照準を合わせた(HP: {_target.Hp})");
        }

        private void btnAttack_Click(object sender, EventArgs e)
        {
            if (_target == null) return;
            if (_target.IsDead)
            {
                btnAttack.Enabled = false;
                return;
            }

            _target.ReceiveHit();

            // ログ出力(会心は別行で通知)
            lstLog.Items.Add($"{_target.Name}に{_target.LastDamage}ダメージ! 残りHP: {_target.Hp}");
            if (_target.WasCriticalLastHit)
            {
                lstLog.Items.Add("会心の一撃!");
            }

            // テスト観点(状態検証)
            Debug.Assert(_target.Hp >= 0, "HPは0未満にならないはず");
            if (_target.IsDead)
            {
                lstLog.Items.Add($"{_target.Name}を倒した!");
                btnAttack.Enabled = false; // 撃破後は攻撃不可
            }
            else
            {
                btnAttack.Enabled = true;  // まだ戦える
            }
        }
    }
}

Form1.Designer.cs(最小レイアウト:敵ListBox+攻撃ボタン+ログ)

namespace MonsterGame
{
    partial class Form1
    {
        private System.Windows.Forms.ListBox lstEnemies;
        private System.Windows.Forms.Button btnAttack;
        private System.Windows.Forms.ListBox lstLog;

        private void InitializeComponent()
        {
            this.lstEnemies = new System.Windows.Forms.ListBox();
            this.btnAttack = new System.Windows.Forms.Button();
            this.lstLog = new System.Windows.Forms.ListBox();
            this.SuspendLayout();
            // 
            // lstEnemies
            // 
            this.lstEnemies.FormattingEnabled = true;
            this.lstEnemies.ItemHeight = 12;
            this.lstEnemies.Location = new System.Drawing.Point(20, 20);
            this.lstEnemies.Name = "lstEnemies";
            this.lstEnemies.Size = new System.Drawing.Size(140, 88);
            this.lstEnemies.TabIndex = 0;
            // 
            // btnAttack
            // 
            this.btnAttack.Enabled = false;
            this.btnAttack.Location = new System.Drawing.Point(180, 20);
            this.btnAttack.Name = "btnAttack";
            this.btnAttack.Size = new System.Drawing.Size(80, 30);
            this.btnAttack.TabIndex = 1;
            this.btnAttack.Text = "攻撃";
            this.btnAttack.UseVisualStyleBackColor = true;
            // 
            // lstLog
            // 
            this.lstLog.FormattingEnabled = true;
            this.lstLog.ItemHeight = 12;
            this.lstLog.Location = new System.Drawing.Point(20, 120);
            this.lstLog.Name = "lstLog";
            this.lstLog.Size = new System.Drawing.Size(240, 124);
            this.lstLog.TabIndex = 2;
            // 
            // Form1
            // 
            this.ClientSize = new System.Drawing.Size(284, 261);
            this.Controls.Add(this.lstLog);
            this.Controls.Add(this.btnAttack);
            this.Controls.Add(this.lstEnemies);
            this.Name = "Form1";
            this.Text = "OOP発展:配列・会心・状態検証";
            this.ResumeLayout(false);
        }
    }
}

2) クリティカルヒット(会心)のポイント

  • 敵クラス内に乱数判定を入れ、外側(Form)では同じ呼び出し(ReceiveHit)で扱うのが肝です。
  • これにより「違い(会心の有無や確率、倍率)は中に隠す」を維持できます。
  • 会心の有無・与ダメは WasCriticalLastHit/LastDamage に保持しておき、ログで表示します。

3) テスト観点の反映

  • HPが0未満にならない:ReceiveHit で next < 0 を0に丸める処理を実装。
  • 撃破時に攻撃不可:IsDead を見て btnAttack.Enabled = false。
  • さらに、Debug.Assert(_target.Hp >= 0, “…") のような簡易検証をイベント処理内に入れておくと、開発時に異常を早期発見できます(リリースビルドでは無視されます)。

追加ヒント(任意)

  • 選択中の敵が倒れたら自動で次の生存敵を選ぶなどの挙動にすると、UI体験が向上します(配列走査で実装可)。
  • 派生クラスごとにCritChance/CritMultiplierを変えると、敵ごとの“個性”が出せます(例:スライムは会心が出やすい等)。

以上が「発展(次の一歩)」のサンプル解答です。


まとめ

  • 手で動く→便利さ→用語の順は、学習ハードルを大きく下げます。
  • “違いは中に隠す”(分岐が消える)体験が、クラス設計の価値を直感で理解させます。
  • 本記事の3ステージを順に進めれば、どうしてもOOPが苦手な学習者でもつまずかずにポリモーフィズムの入口まで到達できます。

付録:最小 Form1.Designer.cs(必要な場合)

すでにデザイナで置いた方は不要です。自動生成コードは手で編集しないのが原則ですが、教材用に最小例を示します。

namespace MonsterGame
{
    partial class Form1
    {
        private System.Windows.Forms.Button btnSlime;
        private System.Windows.Forms.Button btnGoblin;
        private System.Windows.Forms.Button btnAttack;
        private System.Windows.Forms.ListBox lstLog;

        private void InitializeComponent()
        {
            this.btnSlime = new System.Windows.Forms.Button();
            this.btnGoblin = new System.Windows.Forms.Button();
            this.btnAttack = new System.Windows.Forms.Button();
            this.lstLog = new System.Windows.Forms.ListBox();
            this.SuspendLayout();
            // 
            // btnSlime
            // 
            this.btnSlime.Location = new System.Drawing.Point(20, 20);
            this.btnSlime.Name = "btnSlime";
            this.btnSlime.Size = new System.Drawing.Size(80, 30);
            this.btnSlime.TabIndex = 0;
            this.btnSlime.Text = "スライム";
            this.btnSlime.UseVisualStyleBackColor = true;
            // 
            // btnGoblin
            // 
            this.btnGoblin.Location = new System.Drawing.Point(120, 20);
            this.btnGoblin.Name = "btnGoblin";
            this.btnGoblin.Size = new System.Drawing.Size(80, 30);
            this.btnGoblin.TabIndex = 1;
            this.btnGoblin.Text = "ゴブリン";
            this.btnGoblin.UseVisualStyleBackColor = true;
            // 
            // btnAttack
            // 
            this.btnAttack.Enabled = false;
            this.btnAttack.Location = new System.Drawing.Point(220, 20);
            this.btnAttack.Name = "btnAttack";
            this.btnAttack.Size = new System.Drawing.Size(80, 30);
            this.btnAttack.TabIndex = 2;
            this.btnAttack.Text = "攻撃";
            this.btnAttack.UseVisualStyleBackColor = true;
            // 
            // lstLog
            // 
            this.lstLog.FormattingEnabled = true;
            this.lstLog.ItemHeight = 12;
            this.lstLog.Location = new System.Drawing.Point(20, 60);
            this.lstLog.Name = "lstLog";
            this.lstLog.Size = new System.Drawing.Size(280, 124);
            this.lstLog.TabIndex = 3;
            // 
            // Form1
            // 
            this.ClientSize = new System.Drawing.Size(324, 201);
            this.Controls.Add(this.lstLog);
            this.Controls.Add(this.btnAttack);
            this.Controls.Add(this.btnGoblin);
            this.Controls.Add(this.btnSlime);
            this.Name = "Form1";
            this.Text = "OOP入門:違いは中に隠す";
            this.ResumeLayout(false);
        }
    }
}

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