質問をすることでしか得られない、回答やアドバイスがある。

15分調べてもわからないことは、質問しよう!

新規登録して質問してみよう
ただいま回答率
85.50%
C#

C#はマルチパラダイムプログラミング言語の1つで、命令形・宣言型・関数型・ジェネリック型・コンポーネント指向・オブジェクティブ指向のプログラミング開発すべてに対応しています。

Q&A

解決済

5回答

3836閲覧

C#で敵のinterfaceを実装しようとすると、コード管理が難しくなりそう

uroboros

総合スコア20

C#

C#はマルチパラダイムプログラミング言語の1つで、命令形・宣言型・関数型・ジェネリック型・コンポーネント指向・オブジェクティブ指向のプログラミング開発すべてに対応しています。

5グッド

6クリップ

投稿2022/03/02 05:23

C#(Unity)でローグライクゲームを作っています。

簡単なゲーム内容

画面上でプレイヤーが1回行動するたびに、敵キャラクターも行動を起こす、ターン制のローグライクです。
その「敵の行動パターン」が、「敵タイプ」によって様々です。

  1. プレイヤーに近い順に、行動する(全ての敵共通の基本行動
  2. 1マス分プレイヤー方向に移動してくる者
  3. 目の前に破壊できる障害物があれば破壊する者
  4. 敵や障害物を1マスだけなら飛び越えて移動する者
  5. 俊敏で、2マス分移動してくる者
  6. プレイヤーとの距離がしきい値内になってたら攻撃してくる者
  7. 敵自身は全く移動せず、プレイヤーとの距離がしきい値内になってたら攻撃してくる者
  8. 全員の行動が終わったタイミングで、自身が瀕死であれば回復する者
  9. 全員の行動が終わったタイミングで、近くにいる瀕死の敵を回復する者
  10. etc...

イメージ説明

※上記行動パターンは一例。

対するコード(改善前)

上記に対して、以下のようなコードの流れを書いていました。

  • class Enemy は、列挙型フィールド: enemyType行動メソッド: Move()全員行動後に何かするメソッド: ActionAfterMove() を持つ。 画面内にいる敵は、 List<Enemy> = enemies で保持。
  • プレイヤー行動後、 enemies を「プレイヤーに近い順」にソートし foreach で回して行動させる。
  • enemy.Move() では、基本行動を処理したり、 if (enemyType == HOGE) { // HOGE特有処理 } のような敵タイプ毎の特殊な行動を処理する。
  • 全員行動終了後、89のような敵のために、再度 foreachenemies を回して、enemy.ActionAfterMove() を処理する。

上記の問題として、敵タイプが追加されるたびに、 Enemy.Move に変更が入ってしまいます。

対するコード(改善後)

そこでインターフェースについて勉強し、 IEnemy を実装した「敵タイプごとのクラス」を用意しようと考えました。
class Goblin: IEnemy {} みたいなクラスをたくさん作る)

以下が改善後のコードの流れです。

  • interface IEnemy は、列挙型プロパティ: enemyType行動メソッド: Move() を持つ。 画面内にいる敵は、 List<IEnemy> = enemies で保持。
  • プレイヤー行動後、enemies を「プレイヤーに近い順」にソートし foreach で回して行動させる。
  • enemy.Move() では、基本行動を処理したり、敵タイプごとの特殊な行動を処理する。
  • 全員行動終了後、89のような敵のために、再度 foreachenemies を回して、各要素が IActionAfterMoveインターフェース を実装しているか調べて、trueなら ActionAfterMove()を処理する。

気になっている点

以下2点です。

1点目(インターフェース、継承について)

インターフェースなので実装は各敵タイプクラスで行っています。
しかし「基本移動能力」は殆ど同じような処理(敵タイプによっては全く同じだったり、 細かな差異があったり)で、攻撃周りの処理も共通部分が多いです。

こうなると BaseEnemy のような基底クラスを作って、それを継承した派生クラスを使う方が、コード量が少なく、管理もしやすいのではと思ってしまいます。(普通と違う行動を取る場合のみコードを書くようなイメージ)
ただ「継承は使わないほうがいい」という記事がとても多く、その理由についてはあまり理解が追いついていないのが現状です・・・。

また、敵の種類が増えていくと、クラスの数も同様に増えていくので 敵タイプ数 = クラスファイル数 となりそうです。
移動系で少し変更が入った場合、全ての敵タイプクラスの移動メソッド内に修正していくとなると非常に効率が悪いのかな思いました。
改善前のコード、「1つの長いメソッドで全敵行動系がまとまってる」方が、スマートとは思えませんが管理しやすそうに思えてしまいました・・・。

2点目(後半のコードの流れについて)

最初の foreach で全員の行動が完了したあとに、2度目の foreach で各敵の「全員行動後の処理」を行っています。
この処理の流れが妥当なのかを教えていただきたいです。

質問で挙げさせていただいた「全員行動後に行動する敵」というのは一例ですが、特殊なタイミングで行動する敵は多く、その「特殊なタイミング」もさまざまです。
毎回 enemies に対して foreach を回し、「特殊タイミングで行動するインターフェースを実装しているか?」を判定していくことが、果たしてよいのかどうか。
他に「こういう処理のほうがスマート」「このパターンを使えばもっとシンプル」というのがあれば教えていただきたいです。

YakumoSaki, ozwk, Wind👍を押しています

気になる質問をクリップする

クリップした質問は、後からいつでもMYページで確認できます。

またクリップした質問に回答があった際、通知やメールを受け取ることができます。

バッドをするには、ログインかつ

こちらの条件を満たす必要があります。

uroboros

2022/03/06 13:29

みなさん、いろいろな角度から回答をいただきありがとうございました。 返信やBA選択が遅れてしまいましたが、恥ずかしながら・・・回答の意味が全然理解できなかったというのが正直なところです。 多角的にご意見をいただけているので、少しずつ読み解きながら何を伝えてくださってたのかを理解していきたいと思います。
fana

2022/03/07 01:43

わからないところがあるなら,わからない点を問うてみる等すれば良かったんじゃないかなぁ.
guest

回答5

0

ローグライクの行動ターン概念の整理

まず、ローグライクにおける行動ターンの概念を考えてみます。

例えばプレイヤーの移動速度を半分 or 倍速にするような処理に対応できるような世界を考えると

  • キャラクターには1回行動するために必要な「時間単位」が設定されている(鈍足状態なら2単位、倍速状態なら1/2単位が行動に必要)
  • プレイヤーが先に動き、そのあと他のキャラクターも動く
    • プレイヤー行動時は世界からプレイヤーキャラクターに対する「行動実行権」が貸し出された状態で、プレイヤー操作による行動決定が保留されている状態
    • 処理の実行順を決めるための行動優先度が必要
  • プレイヤー操作の1マス移動によって世界の時間が動く = プレイヤー鈍足時・倍速時には世界の時間移動量がそれぞれ2倍・半分になっていないといけない

といった決まりごとが見えてきます。

特殊なタイミングでの行動については、処理の実行優先度のパラメータによって制御します。
行動に必要な時間単位が同じキャラクターでも、「実行優先度が低い方が後に実行されると決めておくことで、特殊な呼び分け処理をなるべく実装せずに、行動順を制御する仕組みを提供できると思います

プレイヤーが動いてから敵を動かすまでの流れは

  • プレイヤーの行動ターンが来て、プレイヤーが1ターンを消費する行動を行う
  • プレイヤーの行動による消費時間単位を参照して、世界の時間進行を進める(プレイヤーが倍速や鈍足を考慮すると、一回行動によって進む時間単位が 1/2 だったり 2 だったりのパターンがあります)
  • プレイヤー以外のキャラクターをループで回して、「前回行動したターンの時間単位 - 現在の時間単位 >= 行動に必要な時間単位」の場合に、システムがキャラクターに行動実行権を渡して1ターン分の行動を行わせる(敵キャラクターが倍速行動の場合は行動処理を必要回数繰り返す)
  • 世界の時間にプレイヤーの行動による消費時間単位を加算して次のプレイヤーの行動待ち、先頭へ戻る

といった感じになると思います。

といったところまで洗い出してみて、どんなクラスとインターフェイスがあれば世界を表現できるか考えてみると

  • 時間単位と存在するキャラクターのリストを保持しキャラクターの行動を行わせる神クラス
  • 神クラスによって行動を実行してもらうキャラクタークラス

の2つに整理できます。

キャラクターの一回行動によって何をするかはキャラクター自身が決めることなので行動は抽象化したままにします。

まずはキャラクターの基礎となるインターフェイスを考えてみます。

cs

1interface IActor 2{ 3 Task ActionAsync(); // 何らかの行動を実行する。行動モーションの終わりまで処理を待機させるためにTaskを返す 4 5 int Priority { get; } // 行動の優先度 6 TimeUnit ActionTime { get; } // 行動に必要な時間単位 7}

TimeUnitを int 等のプリミティブな型にしてしまうと、世界内時間の計算を都度コーディングする際に配慮する必要がでてくるので、構造体として時間単位の内部表現であったり時間の加算減算を定義しておくと便利かと思います。
例えば仕様として4倍速移動までに対応する場合、TimeUnit.One = 12 (2,3,4の最小公倍数)と。浮動小数点数を使ってしまうと3倍速時の時間単位 0.333 の加算で誤差が出て困りそうなので、整数で扱うなど誤差のでないよう注意が必要です。12を1TimeUnitとした場合の3倍速移動は new TimeUnit(4) 、4倍速は new TimeUnit(3) と表せます。

IActorを動かす神クラスは「キャラクターの行動」を管理します。

ですので、どのキャラクターがいつ動いたかも神クラスに持たせておきましょう。

cs

1public class CharacterActionSequenceManager 2{ 3 public List<IActor> Characters { get; set; } 4 public PlayerActor Player { get; set; } 5 public TimeUnit CurrentTime { get; private set; } 6 7 private readonly Dictionary<IActor, TimeUnit> prevActionTimeMap = new (); 8 9 10 // 世界を1ターン進める 11 public async Task AdvanceWorldTime() 12 { 13 // プレイヤーの行動実行を待機 14 await Player.ActionAsync(); 15 16 // 世界の時間をプレイヤーが行動するのに必要だった時間分進める 17 // プレイヤー行動時に倍速化等の効果を反映した上でのPlayer.ActionTimeであることを想定 18 TimeUnit nextWorldTime = CurrentTime + Player.ActionTime; 19 20 // TODO: CharactersをIActor.Priorityによって並び替え 21 // できればCharactersへの要素追加時にソートが完了しているようにしたい 22 23 // プレイヤー以外のキャラの行動を処理 24 foreach (IActor character in Characters) 25 { 26 await ActorActionAsync(character, nextWorldTime); 27 } 28 29 // 世界の現在時間を更新 30 CurrentTime = nextWorldTime; 31 } 32 33 private async Task ActorActionAsync(IActor actor, TimeUnit time) 34 { 35 // 36 TimeUnit prevActionTime = GetActorPrevActionTime(actor); 37 while (time - prevActionTime >= actor.ActionTime) 38 { 39 await actor.ActionAsync(); 40 prevActionTime = time + actor.ActionTime; 41 } 42 43 SetActorPrevActionTime(actor, prevActionTime); 44 } 45 46 private TimeUnit GetActorPrevActionTime(IActor actor) 47 { 48 // TODO: prevActionTimeMap から取得 49 } 50 51 private void SetActorPrevActionTime(IActor actor, TimeUnit time) 52 { 53 // TODO: prevActionTimeMapに設定 54 } 55}

ここまでのコードをベースにすれば、キャラクターの攻撃も移動も回復も、各キャラクターの1ターンにおける全ての行動はIActor.ActionAsync()の実装次第で柔軟に決定できます。

そして、ようやく基底クラスが必要かについてお話できます。個人的には不要だと考えてます。
例えば敵Aと敵Bが全く同じ「隣接マスに対する移動決定ロジック」を持っていたとしても、それは偶然の一致であり、敵A/Bそれぞれのクラスは別々にIActorを実装したキャラクタークラスとして実装すべきです。たとえ敵Bが敵Aの強化版であったとしてもです。

関連があると思う別々のものがあるとき、関連性は外側から与えるべきです。敵Aと敵Bがそれぞれに何か共通する「敵」という概念があるとしても、依存は最小限に留めるべきです。IActorといった最大限抽象化されたインターフェイスにのみ依存するような形が個人的には最善だと考えてます。

じゃあどうやって敵を判別するかというと、後付で意味を与える仕組みを持たせます。例えば「キャラクターIDの100~9999番までは敵とする」という仕様として IActorの判定を補助するメソッドを通して敵かどうかを判定する仕組みを用意します。

キャラクターの行動処理を使い回すには

ここまでキャラクターの行動を抽象化して実行させるための仕組みを紹介してきました。

次にIActor.ActionAsync()の中身として移動と攻撃等の行動をどう管理するかを説明します。もっぱら複数のキャラクターで同じような移動や攻撃の仕組みを使いまわしたりするにはどうすればいいか、ですね。

まずは「通常攻撃するだけのキャラクター」の思考ルーチンを考えると

  • 徘徊する状態
  • プレイヤーを発見して近づいて攻撃する状態

があると思います。

「徘徊状態」の思考ルーチンはほとんどの敵で使いまわせそうですね。一方で近づいて攻撃する場合は、攻撃の射程や攻撃手段によって別々に分けられそうです。

このような場合は思考ルーチンをいわゆる「コンポーネント」として付け外し可能な設計を考えます。

つまり、思考を変数としてキャラクターに持たせた上で、徘徊状態時に「徘徊思考」を、プレイヤー発見時に「攻撃思考」をそれぞれ実行する、という感じです。同様に、回復を行うキャラクターには「回復思考」を用意します。

それぞれの思考に突入する条件は各思考が固有に持っています。また「徘徊思考よりも見つけたプレイヤーへの攻撃思考を優先したい」といった優先条件も考慮する必要がありそうです。

という前提で思考のインターフェイスを考えると

(以下、思考のことを Dicision = 決断 としてます)

cs

1 2public interface ICharacterDicision 3{ 4 int Priority { get; } 5 bool CanEnterState(IActor character, ILevelContext context); // TODO: CanEnterStateのもう少しよい名付け 6 Task DicisionAsync(IActor character, ILevelContext context); 7} 8

となります。ILevelContext にはフロア状況やプレイヤー、他キャラクターの情報など、キャラクターの意思決定に必要となる情報を詰め込んで持たせておきます。

cs

1public class DefaltMoveCharacterDicision : ICharacterDicision 2{ 3 public int Priority => 0; 4 public bool CanEnterState(IActor character, ILevelContext context) 5 { 6 return true; 7 } 8 public async Task DicisionAsync(IActor character, ILevelContext context) 9 { 10 // 移動先の決定と移動アニメーションの実行 11 } 12} 13 14public class MoveCloseAndAttackToPlayerCharacterDicision : ICharacterDicision 15{ 16 public int Priority => 1; 17 public bool CanEnterState(IActor character, ILevelContext context) 18 { 19 // TODO: context からプレイヤーが見つけられるか判定 20 // contextに対する拡張メソッド等として「characterと同じ部屋にプレイヤーがいるか」などの判定処理を持たせるなど共通化を考える 21 } 22 public async Task DicisionAsync(IActor character, ILevelContext context) 23 { 24 // プレイヤーに近づく移動、または攻撃の決定。攻撃の場合はダメージ処理も行う 25 } 26} 27

ICharacterDicision を実行させる場合は、例えば以下のように ILevelContext の拡張メソッドとして処理を共通化できると思います。

cs

1public static class ProcessCharacterDicisionExtensions 2{ 3 public static Task ProcessDicision(this ILevelContext context, IActor character, ICharacterDicision[] dicisions) 4 { 5 foreach (var dicision in dicisions.OrderByDescending(x => x.Priority)) 6 { 7 if (dicision.CanEnterState(character, context)) 8 { 9 return dicision.DicisionAsync(character, context); 10 } 11 } 12 13 throw new InvalidOperationException(); 14 } 15} 16

そうしたら改めてIActor.ActionAsync()で実装するために IActor を少し修正します。

cs

1interface IActor 2{ 3 Task ActionAsync(ILevelContext context); // ILevelContext の引数を追加 4 int Priority { get; } 5 TimeUnit ActionTime { get; } 6}

それでは改めて ICharacterDicision を含めて IActor を実装してみると、

cs

1public class SampleAEnemy : IActor 2{ 3 public int Priority { get; } 4 public TimeUnit ActionTime { get; } 5 6 private readonly ICharacterDicision[] dicisions; 7 8 public SampleAEnemy() 9 { 10 Priority = 1234; 11 ActionTime = TimeUnit.One; 12 dicisions = new [] 13 { 14 new DefaltMoveCharacterDicision(), 15 new MoveCloseAndAttackToPlayerCharacterDicision(), 16 }; 17 } 18 19 public Task ActionAsync(ILevelContext context) 20 { 21 return context.ProcessDicision(this, dicisions); 22 // ↑は ProcessCharacterDicisionExtensions.ProcessDicision(context, this, dicisions); のメソッド呼び出しと同義 23 } 24}

となります。例として簡単なクラスにしましたが、SampleAEnemyのコンストラクタ等を通じてPriorityやICharacterDicisionの受け渡しを行うようにしてもいいと思います。

まとめ

IActorの呼び出しルールを CharacterActionSequenceManager が管理しているためIActorの実装側は自由に組んでいける。という状況を作った上で、ICharacterDicisionとして行動決定の仕組みを変数化し、自由に組み替えられるようにすることで多様な行動を使い回せるようにする設計を示しました。

「敵が増えるとクラス数が増えて管理が大変そう」というのは正直、ある程度は覚悟する必要があります。ただ、ここまでお示しした通り、「全体を処理する仕組み」と「仕組みに乗っかって動くパーツ」を別々に分けることで、キャラクターを増やしても(全体の仕組みには手を付ける必要なく)振る舞い部分を付け足していくだけで済むような設計というのは考えられるのではないかと思います。

補足

敵のバランス調整として、「倍速時に二回移動できるようにしたいが、隣接時の攻撃は一回のみにしたい」となった場合、IActor.ActionAsync() の戻り値として「行動結果」を返すようにして、行動結果に「行動による経過時間」を含めるようにすることで対応出来ると思います。

投稿2022/03/02 10:59

tor4kichi

総合スコア763

バッドをするには、ログインかつ

こちらの条件を満たす必要があります。

0

「敵Aと敵Bは移動方法がちょっと違うけど攻撃の処理とかは同一」っていうのを,「敵Aのクラスと敵Bのクラスを別々に作る」っていう形で実装するか? っていうのは,

「敵AのHPは5で,敵BのHPは7である.AとBの差は他にはない」っていうときに,敵Aのクラスと敵Bのクラスを別々に作るか? っていうのと同じ話と思える.

後者の話を「フィールドとしてHPっていう変数持たせるだけで,クラスは同じでよくね?」と考えることができるとするならば,
前者だって同じハズ.
「フィールドとして 移動 とか 行動 っていう処理を持たせるだけで,クラスは同じで良くね?」っていう.

if (enemyType == HOGE) { // HOGE特有処理 }

っていう実装が可なのであれば,同様に
if (MoveType == XXX) { /* XXX特有処理 */ }
っていう実装も考えられるわけだ.

もちろん,処理分岐の具体的な実装方法は if や switch 以外にも色々あり得るけども,それはそれ.
「何かしらをコンストラクタあたりで与えてしまえばあとはそれを使って処理分岐してればいいよね」と考えてみては.


foreach を複数回やる話については,
「敵ってのがものすごい個体数なのでループを数回やること自体がやばい」とかいう事態に直面しているのでもないならば,別にいいんじゃない?と思う.
「特殊なタイミング」の種類が10も20もあるとかいう話なら,各 foreach で回す集合を同一のものとしなければよい.
(ある特殊なタイミングにおいて「全ての既存敵オブジェクトに関するループ」を回すのが嫌なら,例えば,各敵オブジェクトがMove()メソッド内で「今回,このタイミングで俺のメソッドを呼べ」って能動的に手を挙げるようにでもしとけばどうか)

ただ,

全員行動終了後、8や9のような敵のために、再度 foreach で enemies を回して、各要素が IActionAfterMoveインターフェース を実装しているか調べて、trueなら ActionAfterMove()を処理する。

っていうのは何だかなぁ.
「インタフェースを実装しているかどうか」なんて判断が要るとは思えない.
(簡単には ActionAfterMove() は IEnemy 側に移し,何もしない奴は「中身が空」な形の実装としておけばいいだけでは.)

投稿2022/03/02 07:55

編集2022/03/03 04:36
fana

総合スコア11632

バッドをするには、ログインかつ

こちらの条件を満たす必要があります。

fana

2022/03/02 08:46

> 「継承は使わないほうがいい」という記事がとても多く とのことだけど, 頭ごなしに「継承を使う奴はイモ.使うな!以上!」とか書かれているわけじゃないだろう. 「話はよくわかんねぇけど,使うなって話があるから俺は違う方法でやっとくぜ!」じゃなくて 自身が「継承を使うと良さそう」と考えるのであれば,まずは使ってみればよいのではないかな,とか思う.
fana

2022/03/02 09:48

> 改善前 の class Enemy{ ... } の実装を interface IEnemy{ Move()とActionAfterMove()がある } class Enemy : IEnemy { /*多分ここは何も変わらん*/ } にすれば,とりあえず IEnemy の実装(のうちの1つ)として使えるんでしょ? この形で実装するのが都合が良いタイプのやつらに関しては(いきなり別の実装形態に引っ越さずとも)まずはこれを使ってればいいんじゃない? で,それとは別に > BaseEnemy のような基底クラスを作って… という話で実装する方がやりやすい奴らがいるのであれば,そいつらはそいつらで class BaseEnemy : IEnemy { ... } class XXX : BaseEnemy { ... } って実装すれば良いのだろうし, また別の形で実装した方が楽そうな奴らがいるのであれば別の形にしてみればいい. IEnemyの実装形態が何種類あろうが, そいつらのオブジェクトを「IEnemyを実装してるもの」としてまとめて扱う部分は変わらんのだし. (と,とりあえずいろんな形を書いてみて「こっちの形にまとめた方がいいなぁ」とか見えてきたらそのときにまとめればいいんじゃない?)
guest

0

ベストアンサー

継承について

個人的な意見としては抽象クラスは処理の共通化でなく処理の共通化として活用するほうが好ましいです。その目的はインターフェイスよりもより具体的な仕様と、実装方法の示唆です。私は「これを実装すれば全部同じ流れで動きますよ」ということを保証したいために抽象クラスを作って複数の子クラスで継承することが多いです。
これによって、本来すべきでない継承によって、本来親クラスで規定したかった子クラスの振る舞いが変わってしまうおそれを減らすことができます。
例えば、「データ読み込み機能」はインターフェイスで定義し、「ファイル読み込みクラス」を抽象クラスで定義し、「CSVファイル読み込みクラス」「jsonファイル読み込みクラス」がそれを継承するようにします。これならTSVファイルを読みたければファイル読み込みクラスを継承すればロード処理でのファイル存在チェック等が共通化できるし、「MySqlからの読み込み」が必要になったときに「関係DB読み込みクラス」という抽象クラスが必要になることがわかりやすくなります。

なお、C#では抽象クラスは"Base"を後置することが多いです。よってBaseEnemyでなくEnemyBaseのほうが望ましい名称です。

c#

1public abstract class EnemyBase : IHoge 2{ 3 protected virtual void BeforeMove() { /* 移動処理の共通的な前処理 */ } 4 protected abstract void ExecuteMove(); 5 protected virtual void AfterMove() { /* 移動処理の共通的な後処理 */ } 6 public virtual void Move() 7 { 8 BeforeMove(); 9 ExecuteMove(); 10 AfterMove(); 11 } 12}

たとえばこのように実装すると、子クラスは移動処理本体のExecuteMoveメソッドのみを考えればよく、必要がなければ前処理・後処理は共通化でき、かつ処理順が保証されます。

流れについて

自分自身が後処理する対象かどうかわかるなら、Queue<IAfterMovable>みたいな変数にためておけばいいのでは?とも思いますが。
処理の考え方はforeachで流すやり方の通りかと思いますが、foreachだとテストが大変そうですね。特に、ループ内でやることが増えた場合に困りそうです。

例えば、Publisher-Subscriberパターンとかステートマシンを使って、「味方の行動フレーム」「敵オブジェクトの行動フレーム」「特殊行動オブジェクトの行動フレーム」みたいに3つに分けてそれぞれで処理させるやり方もあると思います。

敵オブジェクト処理サブスクライバーが以下のような流れで処理するとします。

  1. 敵オブジェクトが行動する
  2. もし敵オブジェクトが特殊行動オブジェクトなら特殊行動パブリッシャーに敵オブジェクトをプッシュ通知
  3. await NextState()みたいにして次のステートに移り、自分のフレームまで待機
  4. 次のフレームに来たら先頭に戻る

それとは別に特殊行動サブスクライバーが以下の流れで処理をするとか。

  1. 特殊行動オブジェクトがあるかどうか判定(ない場合、次のフレームまで待機)
  2. 特殊行動する
  3. await NextState()みたいにして次のフレームまで待機
  4. 次のフレームに来たら先頭に戻る

私はゲーム開発はしたことないので自信はないですが、少なくともforeachの中でたくさんのことをやるとテストや機能拡張が難しそうだなぁと思うので、なるべく分けて動かせるようにしたほうがいいかと思います。

投稿2022/03/02 06:21

ry188472

総合スコア74

バッドをするには、ログインかつ

こちらの条件を満たす必要があります。

ry188472

2022/03/02 06:24

書き忘れましたが移動処理そのものは敵クラスと別にインターフェイスとクラスを定義して、処理を委譲させるのがおすすめです。 敵クラスのコンストラクタに移動処理インターフェイスを渡して初期化するイメージです。これなら敵クラスそのものは同じで移動方法が違うオブジェクトが作れますよね?
guest

0

ゲームを作ったわけではないので机上の空論かもしれませんが補足のようなものです。

こうなると BaseEnemy のような基底クラスを作って、それを継承した派生クラスを使う方が、コード量が少なく、管理もしやすいのではと思ってしまいます。(普通と違う行動を取る場合のみコードを書くようなイメージ)

移動系で少し変更が入った場合、全ての敵タイプクラスの移動メソッド内に修正していくとなると非常に効率が悪いのかな思いました。

基底をつくっても、問題は根本的には解決しません。
敵A, B, C, Dがいて、移動パターンがmv1, mv2 とあって、攻撃パターンがat1, at2とあったとして
敵Aはmv1, at1
敵Bがmv1, at2
敵Cがmv2, at1
敵Cがmv2, at2
である場合、何をEnemyBaseに取ればいいのか困ることになります。
仮にAを基底にしたとき、
CとDで全く同じmv2の処理を2回書くことになりますし、
BとDで全く同じat2の処理を2回書くことになります。

なので、「大体の敵が同じ行動を取るから」で基底クラスを作っても、
結局同じ処理を別々のところで書く問題は残ります
(もちろん、減りはするのでそれでいいというならそれまで)

ですから、他の方々がおっしゃるように、行動をクラスにして処理を移譲したほうが見通しがよくなります。

投稿2022/03/02 23:55

編集2022/03/03 00:20
ozwk

総合スコア13512

バッドをするには、ログインかつ

こちらの条件を満たす必要があります。

0

趣味でやっていますがそれでもいいなら。


ただ「継承は使わないほうがいい」という記事がとても多く、その理由についてはあまり理解が追いついていないのが現状です・・・。

確かにそう言われますね。
ですが、これは意味がちょっと違います。

そもそもオブジェクト指向というのは「データ(= フィールド)と処理(= メソッド)をひとまとめにしたオブジェクト」なるものを中心に見る発想法です。
オブジェクトにデータと処理が詰め込まれていますから、オブジェクトだけが対象データと処理方法を知っているという状態とも言えますよね。
たとえばstring型の sというオブジェクトが "Hello"という文字列データを持っているとしたら、このデータを処理・管理するのはsというオブジェクトです。呼び出し側(mainメソッドやらなんやら)は処理方法やデータの状態を意識せずに「2番目から2文字分くれよ」と命令(メッセージング)するだけでオブジェクトが自身に割り当てられた処理方法で管理対象のデータを処理します。

つまり単純にメソッドにまとめる作業(C言語だと関数にする感じの)でブラックボックス化して管理しやすくしているのをメソッドレベルではなくオブジェクトレベルでやっているだけです。

さらにインターフェース等はそのオブジェクトの抽象度を上げるためのものです。

あまりいい例ではありませんが、いわゆる「犬がワンと鳴いて猫がニャーと鳴く」系の説明であれば、
Dogクラス, Catクラスを個別にやるより、Animalインターフェースを用意してこれを実装してDogクラスやらを定義する。そうすると、

Animal animal1 = new Dog();

という風に子でnewして親(インターフェースとか)で保持すると「犬」ではなく「動物」という抽象度の高いものになり、animal1.Bark();のようにすると派生クラス(実装をした側)の処理で行われます。
動かせるのは親であるインターフェース等にあるメンバだけです。
ちなみに子独自に追加したメソッドは直接は呼び出せません。

これは動物の種類に関わらず、Animal[] animals;のように親の配列として保持したりすることで抽象度を高めて「オブジェクトだけが対象データと処理方法を知っている」という状態にするためです。

インターフェースの場合、実装先は常に(どこかで)すべてのメソッドを実装しなければいけません。
これは言い換えると「親であるインターフェースにあるメソッドは常に実装先も持っている」と言えますね。

なので親が持っているメソッドならどのクラスで生成したか関係なく使えるのです。

そして今回の「継承は(出来る限り)するな」というのはこのオブジェクト指向の発想をぶっ壊す人が多いからです。
たとえば単純にクラスAにメソッドを追加するだけの拡張系(?)として継承をしたりとか。
ですがそれは上記の考えには該当しませんので良くないです。どうしてもやるならせめて拡張したいクラスのオブジェクトをフィールドとして保持したクラスを作るべきです。(「継承でやるぐらいならコンポジションでやれ」と言われるアレです)

もし継承を許してしまうとオブジェクトの責務(何をどのように処理・管理するかとか)が複雑化してしまいます。
そのため、禁止とまではいかないにしても「良くないぞ」と言われるのです。

今回の場合、敵の基本的な動きは共通だけど細かい部分で違うと。
それではその敵キャラのクラスを書きだしてみてください。

たとえばRPGで言えば「スライム」「ドラゴン」「オーガ」…とかでしょうか。これらを抽象化するとどうなりますか?
プレイヤー目線で見ると「敵」である程度の動きは同じで「回復系の魔法が使える」「"逃げる"しかない」とかのような独自の動きが違うだけです。その場合は

C#

1// あくまでイメージ 2 3// ちなみにCharacterクラスも同じようにAbstractEnemyを抽象化している 4public abstract AbstractEnemy : Character{ 5 // 指定のキャラに攻撃が当たればtrue, それ以外ならfalseを返す 6 public abstract bool Attack( Character chara ); 7 // 他のメソッドもある 8} 9 10public class Slime : AbstractEnemy{ 11 public overrrde bool Attack( Character chara ){ 12 if( chara.Attacked( 10 ) ) return false; 13 return true; 14 } 15 16 // 他にもメソッドがある 17} 18 19// 呼び出し側で 20AbstractEnemy enemy1 = new Slime(); 21Hero hero = new Hero(); 22if( enemy1.Attack( hero ) ){ 23 // 攻撃が通った 24}

みたいにしてどのクラスでnewしたのか関係なく親が持っているメンバを使うことが可能になります。
簡単に言えば『同一視するため』です。スライムだろうがオーガだろうがドラゴンだろうが敵は敵です。

こんな感じで抽象化したときに共通していてオブジェクトが知っているのならインターフェース等で継承なり実装なりをして同一視したほうがいいかもしれません。

(上記サンプルは所々おかしいところがありますがスルーしてください。あくまで例なので)

投稿2022/03/02 06:25

BeatStar

総合スコア4958

バッドをするには、ログインかつ

こちらの条件を満たす必要があります。

あなたの回答

tips

太字

斜体

打ち消し線

見出し

引用テキストの挿入

コードの挿入

リンクの挿入

リストの挿入

番号リストの挿入

表の挿入

水平線の挿入

プレビュー

15分調べてもわからないことは
teratailで質問しよう!

ただいまの回答率
85.50%

質問をまとめることで
思考を整理して素早く解決

テンプレート機能で
簡単に質問をまとめる

質問する

関連した質問