Edited at
C#Day 3

C#で実装!RPGのパッシブ効果の作り方を通じたオブジェクト指向のノウハウ

More than 1 year has passed since last update.

きよしこの夜、RPG制作楽しんでるナムアニクラウドです!

本日はRPG好きの私がパッシブ効果について語っちゃうよ。

プログラミング脱初心者くらいの人は、プログラムの読み書きはできるようになったけど、これをどうすれば具体的なゲームを作れるの?と疑問に思っている人も多いハズ。今回はそんな方に対してゲーム制作のとっかかりになる記事を目指して(あと自分の趣味を爆発させる目的で)書きます。

今回書くプログラムは同期的な書き方をするのでDirectXなどを使った派手なグラフィックのゲームにそのまま使えるものではないのですが、コンソールの文字だけのプログラムで動かすのには適していますし、派手なグラフィクのゲームでも今回の考え方は役立ちますのでぜひ挑戦してみてください。


あれもこれもパッシブ効果

パッシブ効果とは、RPGの戦闘においてプレイヤーの操作なしに発動する効果のことです。ポケモンで言えば「特性」ですね。「状態異常」や「パッシブスキル」と呼ばれるものもパッシブ効果です。逆にパッシブ効果じゃない物は、ポケモンで言えば「技」です。とにかくプレイヤーがゲームを操作して発動させるものはパッシブ効果ではないものであり、私は「アクティブ効果」と呼んでいます。

パッシブ効果はアクティブ効果に比べて種類が豊富です。どんなものがパッシブ効果なのでしょうか?


  • パッシブスキル:戦闘外などでキャラクターが一旦覚えると、いつでも発動し続けているスキル

  • 状態異常:戦闘中にかかることで期間限定で効果を発揮するもの。毒や眠りなどの他に、攻撃力上昇とかもこのカテゴリ

  • 特殊能力:キャラクターに元々備わっている効果。ダメージを与えると怒りだす敵などもこのカテゴリ

  • 天候:ポケモンでおなじみ。戦闘しているキャラクター全員に効果がある

こんな具合で、パッシブ効果はRPGなら必ずと言っていいほど出会うギミックなのです。これがプログラムでサラッと書けると、RPG作りが楽しくなる気がしませんか?


パッシブ効果を作ろう

そんなパッシブ効果はどのように作られているのでしょうか?私の作っているゲームをなぞる感じで軽く紹介いたします。


下準備

まずはRPGもどきのプログラムを書きます。ターン制で、敵も味方も一人きりのものにしましょう。まずは戦闘に参加しているキャラクターのクラスを作ります(バトラーと呼ぶことにします)。

class Battler

{
// 体力
public int Hp { get; set; }

public void OnTurn()
{
// 自分のターンの処理
}
}

そして、戦闘システムのプログラムは以下の通りです。戦闘が自動で進み、HPの変化が表示されるだけのシステムです。HPが0になっても戦闘が続きますがご愛嬌ということで……

class Program

{
public static void Main(string[] args)
{
Battler player = new Battler { Hp = 100 };
Battler enemy = new Battler { Hp = 100 };

while(true)
{
// ターンの処理。アクティブスキルを使わせたり、いろいろする
player.OnTurn();
enemy.OnTurn();

Console.WriteLine($"プレイヤーのHP:{player.Hp}");
Console.WriteLine($"敵のHP:{enemy.Hp}");
Console.ReadKey();
}
}
}


毒状態の作り方

ターンの終わりにダメージを受ける「毒状態」を作ってみましょう。まずは何も考えずにクラスを作ります。

class PoisonStatus

{
}

毒状態に必要なものを追加しましょう。以下のものが必要と思われます:


  • 毒状態にかかっているキャラクターのオブジェクト


    • それを初期化するコンストラクタも必要



  • 毒ダメージの量

  • ダメージを発生させるメソッド

class PoisonStatus

{
// 戦闘参加者を表すBattlerクラスがあるとします
public Battler Owner { get; set; }
public int Power { get; set; }

public PoisonStatus(Battler owner, int power)
{
Owner = owner;
Power = power;
}

// あとで、戦闘を支配するクラスに呼んでもらう
public void OnTurnEnd()
{
Console.WriteLine($"プレイヤーは毒で{Power}ダメージ");
Owner.Hp -= Power;
}
}

ドカッと書いてしまいましたね。順番に解説してみます。

まず、毒状態は戦闘参加者のひとり(バトラーと呼びます)にダメージを与える操作を行うので、毒状態にかかっているバトラーへの参照をプロパティOwnerに持っておきます。次に、毒で実際に受けるダメージ量も指定する必要がありますので、その量を保存するプロパティPowerも必要です。これら2つのプロパティを初期化するために、コンストラクタPoisonStatusも用意しました。

次にバトラーに手を加えます。毒状態にかかる機能のために、以下のように実装を追加します。

class Battler

{
// 体力
public int Hp { get; set; }
// 毒状態にかかっていればインスタンスが入っており、かかっていなければnull
public PoisonStatus Poison { get; set; }

public void OnTurn()
{
// 自分のターンの処理
}
}

そして肝心のダメージを発生させる処理ですが、PoisonStatusOnTurnEndなるメソッドを作り、ターンの終了タイミングが来たら戦闘画面を支配しているクラスに呼んでもらうことにしました。戦闘システムに毒の処理を呼び出すコードを追加すると以下のようになります。

class Program

{
public static void Main(string[] args)
{
Battler player = new Battler { Hp = 100 };
Battler enemy = new Battler { Hp = 100 };

// 敵に毒を与える
enemy.Poison = new PoisonStatus(enemy, 10);

while(true)
{
player.OnTurn();
enemy.OnTurn();

if(player.Poison != null)
{
// さっき作ったメソッドを呼ぶ
player.Poison.OnTurnEnd();
}
if(enemy.Poison != null)
{
enemy.Poison.OnTurnEnd();
}

Console.WriteLine($"プレイヤーのHP:{player.Hp}");
Console.WriteLine($"敵のHP:{enemy.Hp}");
Console.ReadKey();
}
}
}

このプログラムを開始すると適切な初期化をして敵に毒を与えたのち、


  • プレイヤーのターン

  • 敵のターン

  • プレイヤーのターン終了処理

  • 敵のターン終了処理

という流れを、無限に繰り返しています。

OnTurnというメソッドが出てきますが、その中には対称のバトラーのターンが来たときに実行する処理が書いてあります。毒状態にかかるきっかけとなるアクティブスキルなども、ゆくゆくはこの中で呼ばれるでしょう。そしてその後の部分で、バトラーが毒状態にかかっていればPoisonStatusOnTurnEndメソッドを呼び出します。ここではBattlerクラスのPoisonプロパティがnullでなければ毒状態にかかっているとみなしています。

雑でしたが、毒状態を作ったりするのはこんな雰囲気でできます。続けて他の状態異常も考慮してみましょう。


状態異常を一般化する

状態異常は毒状態だけではありません。たとえば、ターンが経過するとHPが回復する「リジェネ」を実装するとどうなるでしょうか?それは次のようなクラスで表されるでしょう:

class RegenerationStatus

{
public Battler Owner { get; set; }
public int Amount { get; set; }

public RegenerationStatus(Battler owner, int amount)
{
Owner = owner;
Amount = amount;
}

public void OnTurnEnd()
{
Owner.Hp += amount;
}
}

バトラーのHPを操作する状態異常なので、バトラーへの参照をプロパティOwnerに持っています。また、回復量をAmountプロパティに持っています。それから、OnTurnEndメソッドを前述の戦闘システムのメソッドから呼んでもらうことで、HPの回復処理を実現することにしました。

バトラーがリジェネにかかっている状態を表現するために、Battlerクラスを少し弄ります。

class Battler

{
public int Hp { get; set; }
public PoisonStatus Poison { get; set; }
// リジェネにかかっていなければnull
public RegenerationStatus Regeneration { get; set; }

public void OnTurn()
{
// 自分のターンの処理
}
}

そうすると、戦闘システムに少し追加すればリジェネの実装が完了しそうですね。

class Program

{
public static void Main(string[] args)
{
Battler player = new Battler { Hp = 100 };
Battler enemy = new Battler { Hp = 100 };

// 敵に毒を与える
enemy.Poison = new PoisonStatus(enemy, 10);
player.Regeneration = new RegenerationStatus(player, 10);

while(true)
{
player.OnTurn();
enemy.OnTurn();

// *1 毒状態なら毒の処理を発生させる
if(player.Poison != null)
{
player.Poison.OnTurnEnd();
}
if(enemy.Poison != null)
{
enemy.Poison.OnTurnEnd();
}

// *2 リジェネ状態ならリジェネの処理を発生させる
if(player.Regeneration != null)
{
player.Regeneration.OnTurnEnd();
}
if(enemy.Regeneration != null)
{
enemy.Regeneration.OnTurnEnd();
}

Console.WriteLine($"プレイヤーのHP:{player.Hp}");
Console.WriteLine($"敵のHP:{enemy.Hp}");
Console.ReadKey();
}
}
}

おや……ちょっと戦闘システムが長くなってきましたね。というか、*1の部分と*2の部分がすごく似ていて、PoisonRegenerationに変わったくらいの違いしかありません。実際のところ、PoisonStatusRegenerationStatusも状態異常の一種であるし、2つのOnTurnEndメソッドはどちらも「ターン終了時に呼ばれる」という点で同じ役割のはずです。このような同じ役割を持つメソッドを持ったクラス群は、基底クラスを作って共通に扱えるようにしたほうが良いでしょう。

そこで、BattlerStatusなるクラスを作り、PoisonStatusRegenerationStatusはそれを継承する形にします。さっそくやってみましょう。

まず、BattlerStatusクラスは次のようになります。

class BattlerStatus

{
public abstract void OnTurnEnd();
}

それから、PoisonStatus, RegenerationStatusクラスでは、OnTurnEndメソッドにoverrideキーワードをつけてBattlerStatus.OnTurnEndメソッドをオーバーライドするようにします。

class PoisonStatus : BattlerStatus

{
// (前回と同じ部分なので略)
// :

public override void OnTurnEnd()
{
Owner.Hp -= Power;
}
}

class RegenerationStatus : BattlerStatus

{
// (前回と同じ部分なので略)
// :

public override void OnTurnEnd()
{
Owner.Hp += Amount;
}
}

これで、PoisonStatusクラスもRegenerationStatusクラスも、同じBattlerStatusクラスの変数に代入できるようになります("アップキャスト"という仕組みのことですね)。

次に、Battlerクラスがいろいろな状態異常を統一的に扱えるようにする必要があります。新しいBattlerクラスは次のようになります。

using System.Collections.Generic;

class Battler
{
public int Hp { get; set; }
public List<BattlerStatus> Statuses { get; set; }

public void OnTurn()
{
// 自分のターンの処理
}
}

状態異常は複数同時にかかることがあることにして、状態異常のコレクションをListクラスを使って保持することにしました。Listクラスを使うために、System.Collections.Generic名前空間をusingしています。

そして、戦闘システムは次のようになるでしょう:

class Program

{
public static void Main(string[] args)
{
Battler player = new Battler { Hp = 100 };
Battler enemy = new Battler { Hp = 100 };

// 敵に毒を与える
enemy.Poison = new PoisonStatus(enemy, 10);
player.Regeneration = new RegenerationStatus(player, 10);

while(true)
{
player.OnTurn();
enemy.OnTurn();

foreach(var item in player.Statuses)
{
item.OnTurnEnd();
}
foreach(var item in enemy.Statuses)
{
item.OnTurnEnd();
}

Console.WriteLine($"プレイヤーのHP:{player.Hp}");
Console.WriteLine($"敵のHP:{enemy.Hp}");
Console.ReadKey();
}
}
}

ワオ!すっきりしました。しかも、今後どんなに状態異常を増やしてもへっちゃらです!

プログラミングの世界では、「同じことを2度書くな」と言われています。この原則はDRYの原則(Don't Repeat Yourself)と呼ばれています。変数を使ったり、継承を使ったりして、似ている処理はひとつにまとめましょう。


攻撃力を変化させる状態異常

また別の話題に入ってみます。

「攻撃力上昇」の状態異常を作ってみましょう。まずは、攻撃処理を作ってからがよいでしょう。ここまでなんとなく存在していたOnTurnメソッドを実装する形で攻撃処理を実現しましょう。新しいOnTurnメソッドは次の通りです。

using System.Collections.Generic;

class Battler
{
public int Hp { get; set; }
public List<BattlerStatus> Statuses { get; set; }

public void OnTurn(Battler rival)
{
// ライバルを攻撃
rival.Hp -= 10;
}
}

ターンが来たらライバルを攻撃してHPを減らすようにしてみました。そのためにOnTurnメソッドの引数を増やしてバトラーを受け取るようにしています。BattleFlow.Battleメソッドではこれを反映する必要があるでしょう。新しいBattleFlow.Battleメソッドは以下の通りです。

public static void Main(string[] args)

{
// [略]初期化処理

while(true)
{
player.OnTurn(enemy); // ライバルを渡してあげる
enemy.OnTurn(player);

// [略]ターン終了時の処理
// [略]HP表示の処理
}
}

それでは実際に「攻撃力上昇」の状態異常を作っていきます。方針としては、ライバルを攻撃するときに呼ばれるメソッドを作り、そのメソッドが元の攻撃力を補正したあとの新しい攻撃力を返すようにするのがよさそうです。

新しい状態異常は、もちろん先程作ったBattlerStatusクラスを継承します。ですので、攻撃するときに呼ばれるメソッドをBattlerStatusクラスに追加します。

class BattlerStatus

{
// 攻撃するときに呼ばれるメソッド
public virtual int ModifyPower(int source) => source;
public virtual void OnTurnEnd() { }
}

ModifyPowerメソッドが攻撃力を変化させるメソッドです。引数で元の攻撃力を受け取り、戻り値で新しい攻撃力を返します。これを攻撃力上昇の状態異常クラスでオーバーライドするわけです。

前回はBattlerStatusに定義するメソッドはabstractをつけて抽象メソッドにしたのですが、今回はvirtualに変更して仮想メソッドにしてみました。攻撃力上昇の状態異常はターン終了処理などは特にありませんので、その実装を省略できるようにするためです。今後いろいろな状態異常を作ると、いちいち全てのタイミングの処理を実装するのがしんどくなってきますので、このようにしました。BattlerStautsクラスでは、OnTurnEndの中では何もしませんし、ModifyPowerからは元の攻撃力がそのまま返されますので、どちらも呼び出しても何もしません。

class PowerUpStatus : BattlerStatus

{
public override int ModifyPower(int source)
{
return source + 5;
}
}

攻撃力が5上がるようにしてみました。

次に、このパッシブ効果を実際に有効にしてみます。ここで読者さんに問題です。攻撃力上昇のパッシブ効果に複数かかっているときに、全ての攻撃力アップを重ねて反映させるにはどうすれば良いでしょうか?例えば、上記の攻撃力上昇状態が2つなら、攻撃力は5+5で10増えます。 ヒント:攻撃力アップが一つだけの場合は次のようにできるでしょう:

public void OnTurn(Battler rival)

{
var power = 10;
power = this.Statuses[0].ModifyPower(power); // 状態異常の0番目が攻撃力アップだとして
rival.Hp -= power;
}

……

では答えです。

public void OnTurn(Battler rival)

{
var power = 10;
foreach(var item in this.Statuses)
{
power = item.ModifyPower(power);
}
rival.Hp -= power;
}

計算用の変数を用意し、状態異常のコレクションをforeachで列挙し、状態異常ごとにModifyPowerメソッドを呼び出すことで、全ての攻撃力アップ状態を反映させることができます。

ちなみに、C#らしいLINQを使った書き方だとつぎのようになります:

public void OnTurn(Battler rival)

{
var power = this.Statuses.Aggreagate(10, (source, x) => x.ModifyPower(source));
rival.Hp -= power;
}

AggreagateというメソッドがLINQのメソッドです。これを使えば、配列の要素を使って元の値を連続で書き換える処理を行うことができます。


おわりに

さて、他にもいろいろ語りたいことはありますが、予想外に長くなって疲れてしまったのでここまでということで!

パッシブ効果の実装は他にもいろいろな工夫が必要な場面があるでしょう。以下のようなものです:


  • 眠りなど、行動不能になる状態異常


    • 行動可能フラグを用意して、攻撃力アップの要領で最終的なフラグの状態を計算することになるでしょう



  • バトラーに他のパッシブ効果をかけるパッシブ効果


    • パッシブ効果の列挙中にパッシブ効果を追加するためには、ToArrayメソッドで作ったコピーを列挙するようにする、などの工夫が必要でしょう



RPGのプログラミングはとても奥が深いです。みなさんもぜひ挑戦してみてください。