👻

オブジェクト指向の勉強を .NET でやらせた話

に公開

はじめに

弟が大学でプログラミングを学び始めて、Unityでゲームを作る講義があったと言うので、せっかくなので完成コードを見せてもらいました。
が…想像以上に巨大なプロジェクトになっていて驚いてしまいました。
それはさておき、巨大なプロジェクトにも関わらず、コードは講義で聞いたことやAIに聞いたコードで構成されているようで、設計もはっきりしておらず、さらにオブジェクト指向もよくわかっていないとのことでした。
こりゃいかん!これ以上コーディングを進める前にオブジェクト指向の基礎くらいは教えておかねば!
と兄心 (おせっかい) が働いてしまいまして。
色々教えこんだという話です。

プロジェクト

Unity のゲームほどリッチなものは作れませんでしたが、今回は Windows で動くフォームアプリを題材にすることにしました。

題材はゲーム内容に活かせるよう、キャラクターと難易度に応じてHPが変わるプログラムとしました。
主な仕様は下記の通りです。

  • 全キャラクター共通の基礎HPがある (100)
  • キャラクターに応じてHPの倍率が変わる
    • 戦士: 12倍
    • 魔女: 8倍
  • 難易度に応じてHPの倍率が変わる
    • Easy: 4倍
    • Normal: 2倍
    • Hard: 1倍
  • 難易度はラジオボタンで切り替えられ、リアクティブにHP表示が変わる

画面はこんな感じ。

コードはGitHubにもあります。
https://github.com/nagiyu/oop-demo

実装例

代表的な実装例を載せておきます。

プレイヤー

継承、オーバーロードを教えたかったので、下記を準備しました。

  • PlayerBase (親クラス)
  • Attcker, Witch (子クラス)

PlayerBase

using WinFormsApp1.Helpers;

namespace WinFormsApp1.Players
{
    internal class PlayerBase
    {
        private readonly int baseHp = 100;

        private int rate = 1;

        protected int GetBaseHp()
        {
            return baseHp;
        }

        protected void SetRate(int rate)
        {
            this.rate = rate;
        }

        public virtual int GetHp()
        {
            return GetBaseHp() * rate * LevelHelper.GetLevelMultiplier();
        }
    }
}

baseHprateはカプセル化っぽくプライベートなフィールドにし、baseHpはgetterを、rateはsetterを用いて操作するようにしました。
baseHpは基礎HPのことで、rateはキャラクターに応じたHP倍率が入ります。
GetHpでHPの倍率計算が行われる仕組みになっています。

Attacker, Witch

namespace WinFormsApp1.Players
{
    internal class Attacker : PlayerBase
    {
        public Attacker()
        {
            SetRate(12);
        }
    }
}

各プレイヤーのクラスはいたってシンプルで、コンストラクタでキャラクターごとのHP倍率を設定するのみです。
元々はオーバーライドを学ばせるために親クラスの GetHp メソッドは子クラスでオーバーライドした個別実装にしていたのですが、なんか冗長だなぁと感じてこの形になってしまいました。
果たして学習目的に適した実装になっているのやか…シンプルにしすぎてしまった感も否めないところです。

ヘルパー

せっかくなのでstaticなクラスについても触れておきたくて、ヘルパーを実装しました。

namespace WinFormsApp1.Helpers
{
    internal static class LevelHelper
    {
        public static int GetLevelMultiplier()
        {
            var level = LevelStore.GetLevel();

            if (level == "Easy")
            {
                return 4;
            }
            else if (level == "Normal")
            {
                return 2;
            }
            else if (level == "Hard")
            {
                return 1;
            }
            else
            {
                throw new ArgumentException("Invalid level. Must be 'Easy', 'Normal', or 'Hard'.");
            }
        }
    }
}

lebel (難易度) を文字列で受け取り、難易度に応じたHP倍率を返すというシンプルなヘルパーです。
本当は難易度の文字列は定数として外出しした方が堅牢でメンテナンス性も高いとは思うのですが、今回教えたかった内容とかなり離れてしまうのでやめました。

状態管理

今回ラジオボタンで難易度を選択して、その難易度をプレイヤーのクラスやヘルパーから参照してHPを算出する必要があったため、難易度の状態管理が必要になります。
状態管理をどうするかはちょっと悩みました。
Windows のフォームアプリなので、いっそフォームが持っている値を正として状態管理をしてもよかったのですが、個人的にあまり好きな形ではありませんでした。
せっかくカプセル化とかも教えていたので、ちゃんと static なフィールドで管理する方針にしてみました。

namespace WinFormsApp1
{
    internal static class LevelStore
    {
        private static string level = "Easy";

        public static string GetLevel()
        {
            return level;
        }

        public static void SetLevel(string newLevel)
        {
            if (newLevel == "Easy" || newLevel == "Normal" || newLevel == "Hard")
            {
                level = newLevel;
            }
            else
            {
                throw new ArgumentException("Invalid level. Must be 'Easy', 'Normal', or 'Hard'.");
            }
        }
    }
}

level フィールドはプライベートにし、getter と setter で操作するようにしています。
また setter では引数が規定の文字列でなければNGになるような実装にしました。
これでスマートな状態管理が可能になりました。

フォーム

using WinFormsApp1.Players;

namespace WinFormsApp1
{
    public partial class Form1 : Form
    {
        private readonly Attacker attacker;
        private readonly Witch witch;

        public Form1()
        {
            InitializeComponent();

            attacker = new Attacker();
            witch = new Witch();

            SetLabel2Text();
            SetPlayersHp();
        }

        private void radioButton1_CheckedChanged(object sender, EventArgs e)
        {
            if (radioButton1.Checked)
            {
                LevelStore.SetLevel("Easy");
                SetLabel2Text();
                SetPlayersHp();
            }
        }

        private void radioButton2_CheckedChanged(object sender, EventArgs e)
        {
            if (radioButton2.Checked)
            {
                LevelStore.SetLevel("Normal");
                SetLabel2Text();
                SetPlayersHp();
            }
        }

        private void radioButton3_CheckedChanged(object sender, EventArgs e)
        {
            if (radioButton3.Checked)
            {
                LevelStore.SetLevel("Hard");
                SetLabel2Text();
                SetPlayersHp();
            }
        }

        private void SetLabel2Text()
        {
            label2.Text = LevelStore.GetLevel();
        }

        private void SetPlayersHp()
        {
            label3.Text = attacker.GetHp().ToString();
            label4.Text = witch.GetHp().ToString();
        }
    }
}

フォームに表示するためのロジック系が主なので、詳細は割愛します。
拘ったポイントだけいくつか記載しておきます。

インスタンスはアプリにつき1つずつとしました。
ラジオボタンを押す度にインスタンスを作り替える構成ならもっとシンプルにできましたが、あまりスマートな形じゃないのでやめました。

またラジオボタンはチェックが入っている時のみ更新処理が走るようにしました。
Checked の分岐が無いとラジオボタンがオフになった時も発火してしまうので、状態変化が二重に行われてしまう事象を防いだものです。

終わりに

弟には隣で見てもらい、「これが継承っていうやつやで」とか「じゃあHPはどうやって計算すればいい?」とかを聞きながら実装を進めていきました。
かなり理解はしてもらえたんじゃないかなと自負しています(ふふん)。

どうしてもオブジェクト指向は座学で学ぶと身に付きにくいものです。
もう Human とか Dog とかのクラスは見飽きました。
今回の『キャラクターごとにHP倍率が変わる』のような実践に近い形の方が、オブジェクト指向の重要さとか便利さも伝わるんじゃないかなと思い、ちょっと頑張ってみました。
(とはいえゲーム好きじゃないと通用しない手なんですけどね…)

ただ、カプセル化なんかは今回あまりいい教材にならなかったような気はしています。
オーバーライドも結局はリファクタリングで排除してしまいました。
上手な教材って難しいものですね…。

今後も色んな題材を考えてみようと思います。

Discussion