【ゲーム開発のためのC#入門講座・応用強化編】最終課題にチャレンジしてみよう【#6】

2022-08-286.0_C#応用強化編

まずは一言

ここまでありがとうございました&お疲れ様でした!

入門講座本編はおしまい!

これにて基礎編および応用編からなる入門講座本編はおしまいです。

変数やループ処理といったプログラムの基本からオブジェクト指向まで、現代プログラミング技術に関わる部分は一通り学習してきました。

知識だけでいえばゲーム制作をやっていく上で困ることはないでしょう。もし知らないことが出てきたとしても、それはこれまで学習してきたことの延長なので、「なんもわからねえ……」ということはまずないはずです。

といっても、「知っている」と「使いこなせる」は別物でもありますよね。なので、ここから先は実際にゲームを作りながら、知識を体と頭に宿る技術に変換していく必要があります。

ゲーム制作は大変ですが、思い描いた世界を形に出来る楽しさがあります。

楽しんでゲーム開発を続けていけば、自然と知識が技術になっていることでしょう。

さて、話を戻して、最終課題にチャレンジ……の前に、最後の知識、列挙型switchについて学んでおきましょう。

列挙型って?

列挙型は複数の意味付けを持つ定数をまとめて定義できるような機能です。

早速例を見てみましょう。

Console.WriteLine(Level.Endless);
​
public enum Level
{
    Level1,
    Level2,
    Level3,
    Level4,
    Level5,
    Endless
}
コンソール画面

Endless

「enum」というのが列挙型の定義になります。

構文は下記の通り。

アクセス修飾子 enum 列挙型名
{
    定義項目名,
    必要なだけ,
    いくらでも,
    定義できるよ!
}

定数をまとめて定義できる「ようなもの」と言ったのには理由があります。

この列挙型は特殊な型で、各項目はそれぞれ内部的に数値を持っています。

その証拠に、列挙型は個々の値に紐づく数値を指定することができます。

public enum Level
{
    Level1 = 0, // 0からスタートしてもOK(未設定だと勝手に0スタート)
    Level2 = 2,
    Level3 = 3,
    Level4 = 0, // こんな風に順番じゃなくてもいいし、
    Level5 = 5,
    Endless = 99 // 数字が飛んでも大丈夫だよ!
}

この数値は整数(Integer)でなければならず、小数点以下の値を含む数値や文字列は設定できません。

Console.WriteLine(Level.Endless);
​
public enum Level
{
    Level1 = 0.05, // 小数点以下の数値とか、
    Level2 = "hoge", // 文字列だとエラーになっちゃう!
    Level3 = 3,
    Level4 = 0,
    Level5 = 5,
    Endless = 99
}

なので、整数型定数のみまとめて定義できるような機能、というのが一番近いです。

「めちゃくちゃ制限多いけど、何か使い道あるの?」と思われるかもしれません。

が、これが結構便利なのです。

何が便利かというと、

  1. 想定外の値を設定できないようにすることができる(作り手視点)
  2. 入力補完で設定できる値の範囲がわかるようになる(利用者視点)

という点で使い勝手を向上させることができるのです。

例えば先程のプログラムをただの定数で定義し、指定されたレベルを出力するstaticメソッドを追加するとしましょう。

const string Level1 = "Level1";
const string Level2 = "Level2";
const string Level3 = "Level3";
const string Level4 = "Level4";
const string Level5 = "Level5";
const string Endless = "Endless";
​
DisplayLevel(Endless);
​
static void DisplayLevel(string level)
{
    Console.WriteLine(level);
}

これでも先程と同じ結果が出力されますね。

ただ、このメソッド「DisplayLevel」の引数はstring型なので、やろうと思えば定義された定数以外でも設定することができてしまいますよね。

const string Level1 = "Level1";
const string Level2 = "Level2";
const string Level3 = "Level3";
const string Level4 = "Level4";
const string Level5 = "Level5";
const string Endless = "Endless";
​
// ↓やろうと思えばこんなこともできてしまう!
DisplayLevel("hoge");
​
static void DisplayLevel(string level)
{
    Console.WriteLine(level);
}

ですがもしこの引数を列挙型にすると、その列挙型で定義した範囲の値しか設定できなくなります。

// ↓列挙型なら定義した値以外の設定を防ぐことができる!
DisplayLevel("hoge"); //これはエラー
​
static void DisplayLevel(Level level)
{
    Console.WriteLine(level);
}
​
public enum Level
{
    Level1,
    Level2,
    Level3,
    Level4,
    Level5,
    Endless
}

これなら想定外の利用を防ぐことができるので、プログラムを安全に使ってもらうことができますね!

一方、使う側もバグらせたろと思って使う人はそうそういないはず。プログラムを作った人の想定通りに利用させてもらいたいところですが、引数がstring型だとすると、どんな値を設定すればOKなのかぱっと見わからないですよね。

// ↓引数に文字列を設定できるらしいが、
//  どんな文字列でもいいのか、
//  それとも何かルールがあるのか、よくわからない!
DisplayLevel(引数);

ですが引数が列挙型であれば、入力補完機能でどんな値を設定できるのかが一覧で表示されます。どんな値を設定すればいいのかということで悩まなくても済むんですね。

// ↓メソッドを呼び出そうとすると……。
DisplayLevel(
​
static void DisplayLevel(Level level)
{
    Console.WriteLine(level);
}
​
public enum Level
{
    Level1,
    Level2,
    Level3,
    Level4,
    Level5,
    Endless
}
入力候補が出てきた!

便利ですね!

実は前回の「データをJSON形式でセーブ&ロードしよう」でも使っていました。

// ↓FileModeとFileAccessは列挙型!
using (FileStream fileStream = new FileStream(filePath, FileMode.OpenOrCreate, FileAccess.Read))

もしまとまりのある値を定義したい時にはこの列挙型を活用するとよいでしょう。

また、列挙型の変数や引数がどの項目かで処理を分けたい場合、if文よりも便利な条件分岐があります。

それがswitchです。

switchって?

switchは複数の値に対する処理分岐をまとめて記述する条件分岐構文です。

早速実例を見てみましょう。

Level level = Level.Endless;
​
switch (level)
{
    case Level.Level1:
        Console.WriteLine("レベル1の処理!");
        break;
    case Level.Level2:
        Console.WriteLine("レベル2の処理!");
        break;
    case Level.Level3:
        Console.WriteLine("レベル3の処理!");
        break;
    case Level.Level4:
        Console.WriteLine("レベル4の処理!");
        break;
    case Level.Level5:
        Console.WriteLine("レベル5の処理!");
        break;
    default:
        Console.WriteLine("上記以外の処理!");
        break;
}
​
public enum Level
{
    Level1,
    Level2,
    Level3,
    Level4,
    Level5,
    Endless
}
コンソール画面

上記以外の処理!

switchは特定の値に対して「この値だったら~」を書き連ねていく条件分岐です。

構文は下記の通り。

switch (特定の値)
{
    case 値1:
        ……値1だった場合の処理……
        break;        
    case 値2:
        ……値2だった場合の処理……
        break;
    default:
        ……上記以外だった場合の処理……
        break;
}

同じことはif文でもできますが、switchの方がちょっとだけすっきり書けます。

// ↓if文でも同じことはできるけど……
if (level == Level.Level1)
{
    Console.WriteLine("レベル1の処理!");
}
else if (level == Level.Level2)
{
    Console.WriteLine("レベル2の処理!");
}
else if (level == Level.Level3)
{
    Console.WriteLine("レベル3の処理!");
}
else if (level == Level.Level4)
{
    Console.WriteLine("レベル4の処理!");
}
else if (level == Level.Level5)
{
    Console.WriteLine("レベル5の処理!");
}
else
{
    Console.WriteLine("上記以外の処理!");
}
​
// ↓比べてみると、switchの方がちょっとだけすっきり!
switch (level)
{
    case Level.Level1:
        Console.WriteLine("レベル1の処理!");
        break;
    case Level.Level2:
        Console.WriteLine("レベル2の処理!");
        break;
    case Level.Level3:
        Console.WriteLine("レベル3の処理!");
        break;
    case Level.Level4:
        Console.WriteLine("レベル4の処理!");
        break;
    case Level.Level5:
        Console.WriteLine("レベル5の処理!");
        break;
    default:
        Console.WriteLine("上記以外の処理!");
        break;
}

switchは特殊なルールとして、各条件毎に「break」を書かなければいけません。これがなければもっとすっきり書けたんですけどね。

また、以前は「同じ値かどうか」でしか条件を記述することができず、以上だったらとか以下だったらという条件は記述できませんでした。

なので、C#のswitchは正直使わなくても、なんて話もあるほどです。

ただ最近のアップデートでパターンマッチという機能が導入され、以上や以下などの判定式も記述できるようにレベルアップしました。

相変わらずbreakは書かなきゃいけないですが、それでも利用用途は増えたので、覚えておいても損はない技術になったかなと思います。

Level level = Level.Endless;
​
// 今はこんな風にcaseに条件式を書くことができるよ!
switch (level)
{
    case <= Level.Level3:
        Console.WriteLine("レベル3以下の処理!");
        break;
    case <= Level.Level5:
        Console.WriteLine("レベル5以下の処理!");
        break;
    default:
        Console.WriteLine("上記以外の処理!");
        break;
}
​
public enum Level
{
    Level1,
    Level2,
    Level3,
    Level4,
    Level5,
    Endless
}

最終課題

それでは正真正銘、応用編最後の課題です。

今回はテンプレートなしで、仕様通りのプログラムを実装してもらいます(順番の並び替え方法だけ教えます)。

実装方法はどんな方法を使ってもいいです。継承やポリモーフィズムを積極的に使ってやるぜでもいいし、そこら辺は正直使う余裕ないからもっと基本的な内容だけで実装しようでもOK。

開発効率向上やバグを少しでも減らすために綺麗なコーディングを目指すのが望ましい一方、プレイヤーからするとどんな実装をしてようが関係ないんですよね。バグがなくて、面白ければよいのです。

大事なのは自分ができる範囲の中で一番いいと思う方法を選ぶことです。

これから先、皆さんが自分の思い描く理想のゲームを作る上でも、大事な考え方です。

そのことを念頭に置いた上で、チャレンジしてみてください。

それでは、Let’s Rock!!(ギルティギア風

仕様

下記仕様を満たすプログラムを作成してください。
日本語表記の部分は自由に英語に置き換えて実装してください。

・「勇者」「魔法使い」「格闘家」と、
 「魔王」「魔王の右手」「魔王の左手」が戦う3VS3の自動戦闘アプリ
・それぞれ「名前」「HP(体力・int型)」「ATK(攻撃力・int型)」
 というステータスを持つ。
・「魔法使い」のみ独自のステータス「MP(マジックポイント)」を持つ。
・「勇者」「魔法使い」「格闘家」はランダム(記載の確率で)に
 「戦う(50%)」「必殺技を使う(30%)」「防御する(20%)」を選択する。
・「魔王」「魔王の右手」「魔王の左手」はランダム(記載の確率で)に
 「戦う(50%)」「必殺技を使う(30%)」「叫ぶ(20%)」を選択する。
・ターン制で、プレイヤー3キャラと敵3キャラで交互に行動する。
・行動「戦う」は、ランダムな対象のHP(体力)に対して
 ATK(攻撃力)× 100~120%(20%の増減はランダム)の
 ダメージを与えるものとする。
・行動「必殺技を使う」は、ランダムな対象のHP(体力)に対して
 ATK(攻撃力)× 150~200%(50%の増減はランダム)の
 ダメージを与えるものとする。
 ただし、「魔法使い」だけは
 「MP(マジックポイント)を10消費する」ものとし、
 ATK(攻撃力)× 200~300%(100%の増減はランダム)の
 ダメージを与えるものとする。
 MPが不足している場合は代わりに行動「戦う」を実行する。
・行動「防御する」は自身を防御状態にし、次に自分の行動順が回ってくるまで
 自分を対象とする攻撃のダメージを半減する。
・行動「叫ぶ」は特に意味のない台詞をコンソール画面に出力するのみ。
 台詞の内容は実装者のセンスに任せる。
 ただしキャラクター毎に異なる台詞とすること。
・HP(体力)が0以下になった場合、その対象は行動不能となる。
・「勇者」「魔法使い」「格闘家」が全員行動不能となった場合、
 「GAME OVER」とコンソール画面に出力し、処理を終了する。
・「魔王」「魔王の右手」「魔王の左手」が全員行動不能となった場合、
 「Victory!!」とコンソール画面に出力し、処理を終了する。
・各キャラクターのステータスは下記の通り設定する。
 〇〇~△△という表記の場合、その範囲内でランダムに設定されるものとする。
 また、名前は任意の名前を設定する。
 ・勇者  :HP500~1000, ATK30~50, SPD20~30
 ・魔法使い:HP300~600, ATK38~64, SPD18~24, MP100
 ・格闘家 :HP800~1400, ATK48~60, SPD25~34
 ・魔王  :HP1800, ATK50, SPD27
 ・右手  :HP600, ATK30, SPD23
 ・左手  :HP600, ATK25, SPD20

答え合わせ(参考)

これが正しい実装という訳ではないです(ちょっと余計なことしてるし)。

あくまで参考として見てもらえれば幸いです。

// Program.cs
​
using FinalBattle;
​
List<PlayerCharacter> playerCharacters = new List<PlayerCharacter>()
{
    new Hero("テリー"),
    new Magician("バーバラ"),
    new Monk("ハッサン")
};
​
List<EnemyCharacter> enemyCharacters = new List<EnemyCharacter>()
{
    new Devil("魔王", "小童が!"),
    new DevilRightHand("魔王の右手", "鼻毛真拳!"),
    new DevilLeftHand("魔王の左手", "今夜はブギーバック!")
};
​
BattleController battleController = new BattleController(playerCharacters, enemyCharacters);
​
while (true)
{
    battleController.PlayerTurn();
    if (battleController.Result == BattleController.BattleResult.Victory)
    {
        break;
    }
​
    battleController.EnemyTurn();
    if (battleController.Result == BattleController.BattleResult.GameOver)
    {
        break;
    }
}
​
if (battleController.Result == BattleController.BattleResult.Victory)
{
    Console.WriteLine("Victory!!");
}
else
{
    Console.WriteLine("GameOver");
}
namespace FinalBattle
{
    internal class BattleController
    {
        public enum BattleResult
        {
            Fighting,
            Victory,
            GameOver
        }
​
        private List<PlayerCharacter> PlayerCharacters { get; set; }
        private List<EnemyCharacter> EnemyCharacters { get; set; }
​
        private TargetSelector TargetSelector { get; set; } = new TargetSelector();
​
        private Random _random = new Random();
​
        public BattleResult Result { get; private set; } = BattleResult.Fighting;
​
        public BattleController(List<PlayerCharacter> playerCharacters, List<EnemyCharacter> enemyCharacters)
        {
            PlayerCharacters = playerCharacters;
            EnemyCharacters = enemyCharacters;
        }
​
        public void PlayerTurn()
        {
            foreach (PlayerCharacter character in PlayerCharacters)
            {
                if (character.HP <= 0)
                {
                    continue;
                }
​
                character.Unguard();
​
                int targetIndex = TargetSelector.Select(EnemyCharacters);
​
                switch (_random.Next(100))
                {
                    case < 50:
                        CalcNormalDamage(character, EnemyCharacters[targetIndex]);
                        break;
                    case < 80:
                        CalcSpecialDamage(character, EnemyCharacters[targetIndex]);
                        break;
                    default:
                        character.Guard();
                        Console.WriteLine(character.Name + "は防御を固めた!");
                        break;
                }
​
                if (IsVictory())
                {
                    Result = BattleResult.Victory;
                    break;
                }
            }
        }
​
        private void CalcNormalDamage(PlayerCharacter attacker, EnemyCharacter target)
        {
            int damage = attacker.Attack();
            target.HP -= damage;
            DisplayAttackLog(attacker.Name, target.Name, damage);
        }
​
        private void CalcSpecialDamage(PlayerCharacter attacker, EnemyCharacter target)
        {
            int damage = attacker.SpecialAttack();
            target.HP -= damage;
            DisplaySpecialAttackLog(attacker.Name, target.Name, damage);
        }
​
        private bool IsVictory()
        {
            foreach (EnemyCharacter character in EnemyCharacters)
            {
                if (character.HP > 0)
                {
                    return false;
                }
            }
            return true;
        }
​
        public void EnemyTurn()
        {
            foreach (EnemyCharacter character in EnemyCharacters)
            {
                if (character.HP <= 0)
                {
                    continue;
                }
​
                int targetIndex = TargetSelector.Select(PlayerCharacters);
​
                switch (_random.Next(100))
                {
                    case < 50:
                        CalcNormalDamage(character, PlayerCharacters[targetIndex]);
                        break;
                    case < 80:
                        CalcSpecialDamage(character, PlayerCharacters[targetIndex]);
                        break;
                    default:
                        Console.WriteLine(character.Name + "は叫んだ!「" + character.Shout() + "」");
                        break;
                }
​
                if (IsGameOver())
                {
                    Result = BattleResult.GameOver;
                    break;
                }
            }
        }
​
        private void CalcNormalDamage(EnemyCharacter attacker, PlayerCharacter target)
        {
            int damage;
            if (target.IsGuard)
            {
                damage = attacker.Attack() / 2;
            }
            else
            {
                damage = attacker.Attack();
            }
            target.HP -= damage;
            DisplayAttackLog(attacker.Name, target.Name, damage);
        }
​
        private void CalcSpecialDamage(EnemyCharacter attacker, PlayerCharacter target)
        {
            int damage;
            if (target.IsGuard)
            {
                damage = attacker.SpecialAttack() / 2;
            }
            else
            {
                damage = attacker.SpecialAttack();
            }
            target.HP -= damage;
            DisplaySpecialAttackLog(attacker.Name, target.Name, damage);
        }
​
        private bool IsGameOver()
        {
            foreach (PlayerCharacter character in PlayerCharacters)
            {
                if (character.HP > 0)
                {
                    return false;
                }
            }
            return true;
        }
​
        private void DisplayAttackLog(string attackerName, string targetName, int damage)
        {
            Console.WriteLine(attackerName + "の攻撃! " + targetName + "に" + damage + "のダメージ!");
        }
​
        private void DisplaySpecialAttackLog(string attackerName, string targetName, int damage)
        {
            Console.WriteLine(attackerName + "の必殺技! " + targetName + "に" + damage + "のダメージ!");
        }
    }
}
namespace FinalBattle
{
    internal class TargetSelector
    {
        private Random _random = new Random();
​
        public int Select(List<PlayerCharacter> battleCharacters)
        {
            while (true)
            {
                int targetIndex = _random.Next(battleCharacters.Count);
                if (battleCharacters[targetIndex].HP > 0)
                {
                    return targetIndex;
                }
            }
            
        }
​
        public int Select(List<EnemyCharacter> battleCharacters)
        {
            while (true)
            {
                int targetIndex = _random.Next(battleCharacters.Count);
                if (battleCharacters[targetIndex].HP > 0)
                {
                    return targetIndex;
                }
            }
​
        }
    }
}
namespace FinalBattle
{
    internal class BattleCharacter
    {
        public string Name { get; protected set; }
        public int HP { get; set; }
        protected int ATK { get; set; }
        public float SPD { get; protected set; }
​
        protected Random _random = new Random();
​
        public int Attack()
        {
            return ATK * (100 + _random.Next(21)) / 100;
        }
​
        public virtual int SpecialAttack()
        {
            return ATK * (150 + _random.Next(51)) / 100;
        }
    }
}
​
namespace FinalBattle
{
    internal class PlayerCharacter : BattleCharacter
    {
        public bool IsGuard { get; private set; }
​
        public int Attack()
        {
            return ATK * (100 + _random.Next(21)) / 100;
        }
​
        public virtual int SpecialAttack()
        {
            return ATK * (150 + _random.Next(51)) / 100;
        }
​
        public void Guard()
        {
            IsGuard = true;
        }
​
        public void Unguard()
        {
            IsGuard = false;
        }
    }
}
namespace FinalBattle
{
    internal class Hero : PlayerCharacter
    {
        public Hero(string name)
        {
            Name = name;
            HP = 500 + _random.Next(501);
            ATK = 30 + _random.Next(21);
            SPD = 20 + _random.Next(11);
        }
    }
}
namespace FinalBattle
{
    internal class Magician : PlayerCharacter
    {
        private int MP { get; set; }
​
        public Magician(string name)
        {
            Name = name;
            HP = 300 + _random.Next(301);
            MP = 100;
            ATK = 38 + _random.Next(27);
            SPD = 18 + _random.Next(7);
        }
​
        public override int SpecialAttack()
        {
            if (MP < 10)
            {
                return Attack();
            }
            else
            {
                MP -= 10;
                return ATK * (200 + _random.Next(101)) / 100;
            }
        }
    }
}
namespace FinalBattle
{
    internal class Monk : PlayerCharacter
    {
        public Monk(string name)
        {
            Name = name;
            HP = 800 + _random.Next(601);
            ATK = 48 + _random.Next(13);
            SPD = 25 + _random.Next(10);
        }
    }
}
namespace FinalBattle
{
    internal class EnemyCharacter : BattleCharacter
    {
        protected string ShoutText { get; set; } = string.Empty;
​
        public string Shout()
        {
            return ShoutText;
        }
    }
}
namespace FinalBattle
{
    internal class Devil : EnemyCharacter
    {
        public Devil(string name, string shoutText)
        {
            Name = name;
            ShoutText = shoutText;
​
            HP = 1800;
            ATK = 50;
            SPD = 27;
        }
    }
}
namespace FinalBattle
{
    internal class DevilLeftHand : EnemyCharacter
    {
        public DevilLeftHand(string name, string shoutText)
        {
            Name = name;
            ShoutText = shoutText;
​
            HP = 600;
            ATK = 25;
            SPD = 20;
        }
    }
}
namespace FinalBattle
{
    internal class DevilRightHand : EnemyCharacter
    {
        public DevilRightHand(string name, string shoutText)
        {
            Name = name;
            ShoutText = shoutText;
​
            HP = 600;
            ATK = 30;
            SPD = 23;
        }
    }
}
コンソール画面(実行結果例)

……
ハッサンは防御を固めた!
魔王は叫んだ!「小童が!」
テリーの攻撃! 魔王に52のダメージ!
バーバラの必殺技! 魔王に165のダメージ!
ハッサンの攻撃! 魔王に59のダメージ!
魔王の必殺技! テリーに85のダメージ!
テリーは防御を固めた!
バーバラは防御を固めた!
ハッサンの攻撃! 魔王に63のダメージ!
魔王の攻撃! ハッサンに58のダメージ!
テリーの攻撃! 魔王に45のダメージ!
Victory!!

最後に

プログラミングって何なんだろうから始まり、変数やループ処理、メモリやクラス、オブジェクト指向など、本当に色んなことを学んできましたね。

ゲーム開発に必要なスキルはすべて揃ったといっても過言ではないでしょう。

これからさらに技術を磨いていきたい、もっともっとプログラミングがうまくなりたいと考えている方は、おすすめの技術書を紹介しているのでそちらも見てみてください。

旅立つ貴方にはエールを。

改めて、本当にお疲れ様でした! Thank you for playing!!

Posted by 夕目紅