2D座標グリッドでプレイするゲームを作成するとします。ゲームには3種類の敵があり、すべて異なる方法で移動します。
Drunkard
:タイプ1の動きを使用して移動します。Mummy
:タイプ1の動きを使用して移動します。ただし、メインキャラクターの近くにいる場合は、タイプ2の動きを使用します。Ninja
:タイプ3の動きを使用して移動します。クラス階層を整理する上で私が思いついたアイデアは次のとおりです。
各敵が以下から派生する単一の基本クラス:
abstract class Enemy:
show() // Called each game tick
update() // Called each game tick
abstract move() // Called in update
class Drunkard extends Enemy:
move() // Type 1 movement
class Mummy extends Enemy:
move() // Type 1 + type 2 movement
class Ninja extends Enemy:
move() // Type 3 movement
問題:
Drunkard
とMummy
の間でコードが共有されていないため。提案1と同じですが、敵はさらに多くのことを行います。
abstract class Enemy:
show() // Called each game tick
update() // Called each game tick
move() // Tries alternateMove, if unsuccessful, perform type 1 movement
abstract alternateMove() // Returns a boolean
class Drunkard extends Enemy:
alternateMove(): return False
class Mummy extends Enemy:
alternateMove() // Type 2 movement if in range, otherwise return false
class Ninja extends Enemy:
alternateMove() // Type 3 movement and return true
問題:
Ninja
は実際には1つの手しか持っていないため、実際には「別の手」はありません。したがって、Enemy
はすべての敵の準表現です。MovementPlanEnemy
を使用して提案2を拡張します。
abstract class Enemy:
show() // Called each game tick
update() // Called each game tick
abstract move() // Called in update
class MovementPlanEnemy:
move() // Type 1 movement
abstract alternateMove()
class Drunkard extends MovementPlanEnemy:
alternateMove() // Return false
class Mummy extends MovementPlanEnemy:
alternateMove() // Tries type 2 movement
class Ninja extends Enemy:
move() // Type 3 movement
問題:
提案1は単純ですが、抽象化のレベルは低くなっています。提案3は複雑ですが、抽象度が高くなっています。
私は「継承よりも合成」についてのすべてと、それがこの全体の混乱をどのように解決できるかを理解しています。ただし、継承を使用する必要がある学校プロジェクトでは、これを実装する必要があります。この制限が与えられた場合、このクラス階層を編成する最良の方法は何でしょうか?これは、継承が本質的に悪い理由の単なる例ですか?
私の制限は継承を使用する必要があるということなので、私はより広い質問をしていると思います:一般に、プログラムアーキテクチャを複雑にすることを犠牲にして、新しい抽象化レイヤーを導入することが適切なのはいつですか?
私は2Dローグライクをほとんどゼロから構築し、多くの実験を経て、まったく異なるアプローチを使用しました。基本的に、エンティティコンポーネントアーキテクチャ。
各ゲームオブジェクトはEntity
であり、Entity
には、プレーヤーや環境からの刺激に対するオブジェクトの反応を制御する多くの属性があります。私のゲームのこれらのコンポーネントの1つはMovable
コンポーネントです(他の例はBurnable
、Harmable
などです my GitHub には完全なリストがあります) :
class Entity
movable
harmable
burnable
freezable
...
オブジェクトの作成時にさまざまな基本コンポーネントを注入することで、さまざまなタイプの敵を区別します。だから次のようなもの:
drunkard = Entity(
movable=SometimesRandomMovable(),
harmable=BasicHarmable(),
burnable=MonsterBurnable(),
freezable=LoseATurnFreezable()
...
)
そして
ninja = Entity(
movable=QuickMovable(),
harmable=WeakHarmable(),
burnable=MonsterBurnable(),
freezable=NotFreezable()
...
)
各コンポーネントは、位置などの情報のためにその所有者Entity
への参照を格納します。
コンポーネントは、ゲームの世界からメッセージを受信し、それらを処理して、結果のメッセージをさらに生成する方法を認識しています。これらのメッセージはグローバルキューに到着し、毎ターンメインループがあり、キューからメッセージをポップし、処理してから、結果のメッセージをプッシュしてキューに戻します。したがって、たとえば、Movable
コンポーネントは、所有するentity
の位置属性を実際に編集するのではなく、変更する必要があるというメッセージとともに、それらを変更する必要があるというゲームエンジンへのメッセージを生成します。所有者に移動する必要があります。
基本的なゲームエンティティには基本的にクラス階層がなく、欠けていることに気づきませんでした。動作は、エンティティが持つコンポーネントによって完全に区別されます。これは、ゲームの世界のすべてのエンティティ、プレーヤー、敵、またはオブジェクトで機能します。
これが、継承よりもインターフェイスを好むことが多い理由です。現実の問題の多くは、オブジェクト階層でモデル化できません。
interface IMove
{
// returns an intermediate location chosen with
// the intention to move toward destination
Point Move(Point currentLocation, Point destination)
}
これで、IMoveを挿入したり、「戦略忍者を使用してこのオブジェクトを移動する」タイプの関数を記述したりできます。
移動戦略を個別にテストすることもできます
私はあなたの最初のオプションに従いますが、その後、さまざまな移動スタイルに戦略パターンを使用します。これにより、移動スタイルを入れ替えたり、移動スタイルを変更したりすることができます。
したがって、MoveStyleというインターフェイスがあり、次に、動きの種類ごとにそれを実装するいくつかのクラスがあります。
あなたの主な質問への答え:
一般に、プログラムアーキテクチャを複雑にする代わりに、抽象化の新しい層を導入するのが適切なのはいつですか。
比較的単純で簡単です:
関係するメリットとコストを確認する必要があります。追加の抽象化レイヤーは、3つのクラスを持つプロジェクトを複雑にしすぎませんが、何十ものクラスが影響を受けるプロジェクトの解決策になる可能性があります。論理的抽象化のリファクタリングintoモデルは非常に多くnontrivialであり、メリットと慎重に比較検討する必要があります。
メリットに関する限り、私が注目する2つの広範な側面は、高い表現力と高い適応性。つまり、コードがより表現力豊かになれば、それはプラスになります。今後の予期しない要件に簡単に適応できるように柔軟になれば、それはさらに大きなプラスになります。
表現力を過小評価しないでください。そこには適応性も隠されているからです。モデル化されたドメインをよりよく理解して「模倣」するほど、潜在的な将来の要件をよりよく「予測」できます。
あなたの見方によっては、あなたがabstractionの本当の意味を混乱させるかもしれません。 詳細を非表示なので、抽象化は強力です。あなたは述べる:
提案1は単純ですが、抽象化のレベルは低くなっています。提案3は複雑ですが、抽象度が高くなっています
いいえ、それは逆の方法です。 「プロポーザル1」は「プロポーザル3」よりも抽象度が高くなっています。あなたが立っている場所の外に一歩踏み出してください。あなたは自分のデザインについて何も知らず、他の人にそれを提示します。
「プロポーザル1」から、Enemy
、show
、およびupdate
を使用できるmove
という名前のエンティティがあることがすぐにわかります。さらに、Enemy
には特定のタイプがあります。
さらに、「提案3」から、Enemy
の特別なタイプがあることも知っています。 MovementPlanEnemy
。一部のタイプでは、基本的なEnemy
タイプの代わりにこれを実装しているため、タイプについてmoreを知っています。あなたはより多くなりつつあります特定、あなたは2つのタイプの動き、プレーンとオルタナティブを提供します。
これらのタイプをどこで使用するかを考えてください。ゲーム内では、最終的に、ロジックを構成できるように、一般的な基本型を宣言する必要があります。型をMovementPlanEnemy
として宣言する場合は常に、それらをEnemy
として宣言する場合よりも詳細が "漏洩"します。私があなたのデザインについてもっと知ったら、あなたは抽象化のはしご、つまり「具体化」に向かって行きますdown。それを行うと、柔軟性が低下するため、通常は表現力を失います。あなたは今より多くの情報を提供し、あなたのコードの将来のバージョンであなたのこれらの特定の規定をサポートしなければならないので、これはより複雑になります(またはあなたを含むそれらを使用するすべての人にあなたの新しい決定に適応するようにコードを変更するように強制します)。
「プロポーザル1」について、次のように述べます。
違反DRYコードはDrunkardとMummyの間で共有されないため。
あなたはそこにあなたの評価でやや厳しすぎるかもしれません。 もちろんコードはDrunkard
とMummy
の間で共有されません。 DRY(Do not Repeat Yourself))は、同じコード行を記述しないことを意味しません。つまり、努力するあなたの概念を再利用するには。タイプ1の動きはconceptであり、たとえば静的ヘルパークラスを使用するか、他の人が述べているように、これについて非常に簡単に繰り返すことを回避できます。構成とインターフェース(戦略パターンなど)を介して。しかし、明日は、既存の2つのキャラクターのように動く別のキャラクターを追加する必要があるかもしれません。動きのタイプをより多くのグループに分けるために、抽象化をもう一度変更しますか?
つまり、一部のクラスが動作の一部を共有しているという事実に対応するためだけに、モデル全体を変更する必要はありません。この理由は、あなたの場合、一般的なコードはcoincidenceであり、実際のdesign detailの決定ではない可能性が高いためです。状況に応じて、それぞれ独自の方法で2Dグリッド上を移動するキャラクターが必要です。 2つの異なる方法(最大で)で動くことができるキャラクターを暗示する他の提案も、最大で2つの方法の実際のサポートを必要とし、突然、メイン全体で追加のチェックを行うことに気付くでしょうゲームロジック。キャラクターの移動方法(つまり、呼び出すメソッドmove()
またはalternativeMove()
)を見つけます。文字がどのように移動するかについての決定は、封じ込め(カプセル化)するのが最善です文字クラス内。追加の詳細を(できれば各文字のコンストラクターを介して)提供することで潜在的に助けられます。この設計選択をサポートする唯一のモデルは「提案1」であり、これを維持し、高度な抽象化レベルを犠牲にすることなく、残りの問題を解決するようにしてください。
Mummy
の代わりにDrunkard
をEnemy
に拡張すると(少し賢い酔っ払いだと主張できます)、条件付き関数はベース(つまりDrunkard
)move()
またはタイプ2の動きを使用します。これは、プロポーザル3のバリアントとして見ることができます。ここでは、alternateMove()
はなく、Drunkard
は、MovementPlanEnemy
classに割り当てた役割を果たします。ちなみに、そのクラスの名前は、@ AdamBの回答で戦略クラスのアプローチをより暗示しています。
DRY違反を防ぐ別の方法は、タイプ1の移動メソッドをすべての敵のクラスの外で実行し、Drunkard
とMummy
に必要に応じてそれを呼び出させることです。このアプローチの実装方法によっては、MoveStyle
クラスも作成するという@AdamBのアイデアに還元できます。さらに別のクラスを作成するためにhaveを使用するのではなく、タイプ1移動方法はどこかに住んでいる必要があります。
@Gqqnbigが述べたように、これらの提案の最初の1つには、メンテナンスという大きな欠点があります。 現在Mummy
がDrunkard
と大きく異なる必要はないため、YAGNIの意味でのみ「機能」するため、このような継承は現在の精神を尊重しますニーズ。 Mummy
Drunkard
から継承すると、長期的にはさらに多くのオーバーライドが必要になる可能性があります。これはゲーム用であるため、最も明白な問題は、最終的には敵のタイプのオーディオビジュアルインジケーターを追加することです。それがおそらくYDNIRNではなくYAGNIと呼ばれる理由です(今は必要ありません)。
(背景知識を少し: 名詞の王国での処刑 、スティーブ・イェゲによる)
Enemyのオブジェクト階層を作成しました。すべての敵の間で十分な共有データと機能がある場合、それ自体は完全に良いことです。ただし、この階層には、合理的に属していない機能を押し込んでいます:show()
、update()
、move()
。
これらの方法:
ここでの多くの回答は、moreオブジェクト、more名詞、インターフェースまたはアスペクトの複雑な組み合わせ、またはキャプチャできる抽象基本クラスを使用することを提案していますミイラと酔っぱらい運動の共通点。しかし、私はこの種の靴角を付けることは悪い考えだと思います。
私見、あなたはゲームロジックがクラス定義に属しているという仮定を手放す必要があり、それが合わない場合はそれをそこに強制する必要があります。
動きに焦点を当てる-あなたは真剣に検討する必要があります:
move()
を独立した関数にするか、Enemyをパラメーターとして使用するBoardクラスなどのメソッドにします。resolve_movement_type()
というヘルパー関数を使用します。これは、敵といくつかの状態情報をパラメーターとして受け取ります(たとえば、プレーヤーからの距離、時間帯など)。次に、より特殊な移動ルーチンを呼び出すことができます-タイプ1、2、または3。おそらく、show()
とupdate()
の精神は似ています。
注:優先損益について言及するのを忘れていました。
概念的に言えば、各Enemy
には単純または複雑な単一の動きがあります。
_abstract class Enemy:
show( )
update( )
move ( )
_
今のところ、すべてのクラスmove
はまったく異なると考えて、サブクラスを追加してみましょう。
_class Drunkard: extends Enemy
/* override */ move ( )
class Mummy: extends Enemy
/* override */ move ( )
class Ninja: extends Enemy
/* override */ move ( )
_
わかりました、move
は混合または単純であり、すべてのサブクラスで単純を使用できるとは限りません。
これに対処するには2つの方法があります。 1つは、次のような保護されたメソッドとして単純なメソッドを追加することです。
_abstract class Enemy:
show( )
update( )
move ( )
/* protected */ simplemove1 ( )
/* protected */ simplemove2 ( )
/* protected */ simplemove3 ( )
_
そして、各サブクラスのmove
メソッドは、必要な単純なメソッドを呼び出します。しかし、敵や動きを追加したい場合、これは役に立ちません。
別の方法は、「インターフェース」に似た「特性」を使用することですが、いくつかのP Lによって実装されていません。
3番目のオプションは、ご存じのとおり、move
操作を別のクラスに委任することです。
_abstract class MoveOperation:
move ( )
abstract class Enemy:
show( )
update( )
move ( )
_
そして、それは余分ですが、まだ有効なレイヤーを追加します。
とりあえず、仮説の動きは簡単です。
_abstract class MoveOperation:
abstract move ( )
class MoveOperation1: extends MoveOperation
abstract move ( )
class MoveOperation2: extends MoveOperation
abstract move ( )
class MoveOperation3: extends MoveOperation
abstract move ( )
_
次に、オーバーライドされた各move( )
操作が、必要なメソッドを作成して呼び出します。
_Ninja.move ( ):
MoveOperation M = new MoveOperation1( )
M.move( )
_
しかし、それらを組み合わせることができるので、単純なクラスと構成されたクラスを作成しましょう:
_abstract class SimpleMove:
abstract move ( )
class MoveOperation1: extends SimpleMove
move ( )
class MoveOperation2: extends SimpleMove
move ( )
class MoveOperation3: extends SimpleMove
move ( )
_
そして、合成された操作:
_abstract class ComposedMove:
abstract move ( )
class DrunkardMove: extends ComposedMove
move ( )
class MommyMove: extends ComposedMove
move ( )
class NinjaMove: extends ComposedMove
move ( )
NinjaMove.move:
SimpleMove1 M1 = new SimpleMove1( )
SimpleMove2 M2 = new SimpleMove2( )
M1()
M2()
_
そしてEnemy
クラスは以下を提供します:
_abstract class Enemy:
/* protected */ ComposedMove Action
show( )
update( )
abstract move ( )
class Drunkard: extends Enemy
/* override */ move ( )
class Mummy: extends Enemy
/* override */ move ( )
class Ninja: extends Enemy
/* override */ move ( )
Ninja.move ( ):
this.Action = new NinjaMove( )
this.Action.move( )
_
これの「美しさ」は、後で他の「敵」を追加できることです。
_class EvilKittyMove: extends ComposedMove
move ( )
class EvilKitty: extends Enemy
/* override */ move ( )
_
乾杯。
mainCharacter
はどこから来たのですか?あなたの説明から、move()
はメインキャラクターのデータに依存する場合とそうでない場合があります。
この状況では、mainCharacter
はインターフェースのmove()
のパラメーターでなければなりません。使用するかしないかは、派生クラスの実装の詳細です。
mainCharacter
がゲームエンジンのグローバルデータまたはコンテキストデータであり、すべてのコードが到達できる場合、それはMummyのmove()
の実装の詳細です。
class Mummy extends Enemy:
move()
if ( self.IsNear( context.MainCharacter ) )
moveType2()
else
moveType1()
ゲームではengine、おそらくreturnを含む複合オブジェクト:
class CalculatedMove
var Sprint
var Location
class Mummy extends Enemy:
move()
return new CalculatedMove( self , calculateNextPositoin( context.MainCharacter ) )
ユーザー@Vectorは重要な点を述べていますが、彼の長い答えは曖昧です:DRYは、同じコード行を記述しないことを意味しません。
そうは言っても、Proposal 1の方が明らかに望ましいですが、不完全かもしれません。
タイプ1の移動を使用した移動のコードを複製することに対する懸念は、少なくともいくつかの異なる方法で対処できます。
オプション1.心配しないで、先に進んで複製します。コードが将来の要件に対応するために進化するとき、Drunkard
とMummy
の動きが完全に同じままになる可能性はどのくらいありますか?この場合、コード行が重複していると、一方を変更すると他方が壊れるという将来のバグの発生を防ぐことができます。
オプション2.移動に関連するコードが重要な場合は、より適切です。移動用に別のクラス階層を作成し、それを敵の属性にします。これで、Movementクラス内でコードを再利用できます。
TL; DR
abstract class Movement:
abstract move() // Called by Enemy code to move itself
abstract class Enemy:
show() // Called each game tick
update() // Called each game tick
abstract movement() // Called in update
class Drunkard extends Enemy:
movement(): return new Type1Movement
class Mummy extends Enemy:
movement(): if isMainCharacter return new Type1Movement else return new Type2Movement
class Ninja extends Enemy:
movement(): return new Type3Movement
class Type1Movement extends Movement:
move(): ... your code here ...
class Type2Movement extends Movement:
move(): ... your code here ...
class Type3Movement extends Movement:
move(): ... your code here ...
コードが仕様に似ているのはいいことです。
仕様は次のとおりです。
酔っぱらい:タイプ1の動きを使用して移動します。
ミイラ:タイプ1の動きを使用して移動します。ただし、メインキャラクターの近くにいる場合は、タイプ2の動きを使用します。
忍者:タイプ3の動きを使用して移動します。
そして、これはそれに似ているようにコードを書く方法です:
enum EnemyType {
Drunkard,
Mummy,
Ninja,
}
class Enemy
EnemyType enemyType
move() {
switch (enemyType) {
case Drunkard:
movetype1()
break
case Mummy:
if closeToPlayer()
movetype2()
else
movetype1()
break
case Ninja:
movetype3()
break
}
}