【Unity】コンポーネント指向とは?と失敗談

はじめに

こんにちは、まきひろ(@makihiro_dev)です。

この記事では、

  • コンポーネント指向とは?
  • 僕がコンポーネント指向を実践して起こった失敗

について書いていきます。

コンポーネント指向で失敗しないために、最後まで読んでもらえると幸いです。

コンポーネント指向とは?

機能を1つ1つの部品に分け、それらを組み合わせることでキャラクターの挙動などを実現する方法です。

例えば、キャラクターを作る時にCharacterコンポーネントを実装するのではなく、

  • HealthコンポーネントでHP管理
  • Moverコンポーネントで移動を管理
  • Attackerコンポーネントで攻撃を管理

このような感じでコンポーネントの役割を分散させ、あとはAIやプレイヤーの入力でこれらのコンポーネントを動作させます。

コンポーネント指向の長所として、以下の2つが挙げられます。

コードが見やすくなる

CharacterコンポーネントでHP管理も移動も攻撃も全部やってしまうと、コードが読みづらくなってメンテナンスが大変になっていきます。

そこで機能を分割することで、コンポーネント名を見れば何をしているかが分かりやすくなります。

付け外しができる

機能の付け外しが可能になります。

  • Healthコンポーネントだけ付けて、壁を実装する
  • HealthとAttackerだけ付けて、砲台を実装する
  • HealthとMoverとAttackerを付けて、キャラクターを実装する

機能別にコンポーネントを作成することで、オブジェクトの機能はコンポーネントの組み合わせによって実現できます。


僕はコンポーネント指向をプロジェクトに取り入れたことで、とても柔軟に機能の実装を行えるようになりました。

コンポーネント指向は一見すると、みんなが幸せになる素敵指向に見えますよね。

しかし、失敗すると地獄と化します。

行き過ぎた機能の細分化

僕がコンポーネント指向でやらかした失敗が、「行き過ぎた機能の細分化」です。

細分化をすればその分柔軟になりますが、細分化をすることによる弊害もあるのです。

コンポーネントを管理するコストの増加

コンポーネント指向を行うと、機能によってコンポーネントを分割することになります。(Characterコンポーネントなら、Health、Mover、Attackerに分割)

すると、少なからずコンポーネントの管理コストが増加します。

実際にコンポーネントの管理コストが上がった例を見てみましょう。

以下のリストは、僕のゲームで実際に1体のキャラクターにアタッチされているコンポーネントの例です。

  • Rigidbody(物理演算)
  • Timeline(時間を管理する)
  • GridObject(グリッドに対応させる)
  • GridMover(グリッドに合わせて動く)
  • JumpMoveEffector(動き方を制御する)
  • Commander(ターン制の動きをする)
  • Agent(AIを制御する)
  • Alignable(敵・味方を区別する)
  • Seeker(経路探索をする)
  • AttackBehaviour(攻撃専用のAIを実装)
  • CommandGridEffector(グリッドに合わせて周囲に影響を及ぼす)
  • Health(HPを実装)
  • Targetable(狙われるオブジェクトに必要)
  • Destroyable(破壊されるときの処理)
  • HealthUIDisplayer(HPのUIを表示する)
  • HealthTween(HPに変化があった時にアニメーションを発生させる)
  • CullingTarget(CullingGroupのカリング対象にする)
  • FieldObject(マップ生成アルゴリズムの生成対象に必要)
  • ItemDrop(死んだらアイテムをドロップする)
  • HealthSound(HPに変化があった時に音を鳴らす)
  • DeathEffect(死んだときにパーティクルを発生させる)
  • TweenElement(集団を合わせてトゥイーンさせる)

…これが、コンポーネントを管理するコストの増加です。

もはやコンポーネント過多で、手に負えなくなっています。

コンポーネント過多でどうなるか、もっと掘り下げていきましょう。

コンポーネント数が増えるとどうなるか

コンポーネント指向の目的は突き詰めれば、開発におけるMP消費を下げることです。

しかし、コンポーネント指向を意識しすぎるとかえって管理コストがかかるようになってしまい、本来の「MP消費を下げる」という目的が死んでしまいます。

コンポーネントをアタッチするのは面倒くさい

「コンポーネントのアタッチに掛かる時間なんてすぐなんだし良いじゃないか」と思う人もいるかもしれませんが、コンポーネントのアタッチでも確実にMPは消費されます。

  1. ゲームオブジェクトを選択
  2. AddComponentを選択
  3. コンポーネントを探す
  4. クリック

このように、コンポーネントの追加は4段階もの工程が発生するからです。

単純作業はキツイものです

新しいキャラクターを実装するときに、24近くのコンポーネントをアタッチする光景を想像してみましょう。

端的に言って地獄です。

コンポーネントを探すコスト

「インスペクターのスクロールでマウスホイールを1周回す」ぐらいコンポーネント過多になってくると、特定のコンポーネントを見たい時に、それを探すための無駄なMPを消費することになります。

つまるところ、「HealthコンポーネントのHPを確認したいな」とか「あの値は正しく設定されているかな?」とかコンポーネントを確認しようとするたびに、MPが削られるわけです。

この作業、地味にキツイ。

「どのコンポーネントをアタッチすればいいんだっけ?」問題

キャラクターなどにアタッチするコンポーネントが過多になってくると、何をアタッチすればいいか分からなくなります。

ゲームの細かな機能を実装する為に、存在することも忘れそうな細かなコンポーネントを大量にアタッチすることになるからです。

新しいキャラクターを作ろうとするたびに、これらの必要なコンポーネントを思い出してアタッチするのを想像してください。

地獄です。


これらの問題は一見すると些細なことに見えて、ボディブローのようにじわじわと着実に効いてきます。

新しいキャラクターなどのコンテンツを追加するモチベーションに支障が出るほどの大問題です。

どうすればいいのか?

コンポーネント指向が暴走すると、どれだけ大変になるかは伝わったと思います。

では、どうすれば健全なコンポーネント指向を行えるのでしょうか?

ここでは、HPを管理するHealthコンポーネントを例に挙げて対策法を解説していきます。

単一責任の原則は大事だが、絶対ではない

よくコンポーネント指向と一緒に持ち出される概念に、単一責任の原則があります。

「クラスの役割は1つであるべき」というルールです。

これを守ってプログラミングをすることで分かりやすいコードを書くことができます。

なので僕は純粋に、単一責任の原則に則ってコンポーネント指向を実践しました。

  • HPを管理するHealthコンポーネント
  • HPをUI表示するHealthUIDisplayerコンポーネント。だってHealthコンポーネントがUIも管理するのって役割が多すぎじゃん。
  • HPが変動したときにアニメーションするHealthTweenコンポーネント。だってHealthコンポーネントがアニメーションも管理するのって役割が多すぎじゃん。
  • HPが変動したときに音を鳴らすHealthSoundコンポーネント。だってHealthコンポーネントが音も管理するのって役割が多すぎじゃん。

その結果が、コレだよ!

この失敗を経て、僕が達した結論は以下のようになります。

コンポーネントの目的において大体必要になる拡張機能は、最初から内包しておくべき

Healthコンポーネントの場合だと、

大体UIで表示することになるのだから、UIを表示する処理を最初から内包しておくべきだし、

大体音を鳴らすことになるのだから、音を鳴らす処理を最初から内包しておくべきだし、

大体アニメーションさせることになるのだから、アニメーションさせる処理を最初から内包しておくべきです。

Healthコンポーネントにこれらの機能は必然的に付随してきます。

じゃあ最初から内包しておけばいいのでは?

とは言ってもこれは『Treasure Rogue』の話で、他のゲームだと処理が変わってくるかもしれません。

UIの仕様は違うかもしれないし、音を管理するライブラリが違うかもしれないし、アニメーションの仕様はもっと複雑かもしれない。

そうなると、この「HPを管理する」以外の機能が詰まったHealthコンポーネントは使いまわせません。

しかし、Healthコンポーネントは使いまわしたいものです。

そういう時は、汎用的なHealthBaseコンポーネントを作ってプロジェクト間で使いまわし、プロジェクトごとにHeatlhBaseを継承した独自のHealthコンポーネントを作成し、そこに独自の処理を組み込んでいきます。

もし使わない処理があるなら、if文で分岐すればいいし、

もしコードが長くなるというのなら、partialキーワードで対応するべきです。

そうすることで、

  • 使いまわしやすさ
  • 柔軟性
  • コンポーネントを管理するコストの軽減

これらが担保されます。

まとめ

ここまでの対策をまとめると、

  • コンポーネントに大体必要になる機能は、最初から内包しておく
  • 使わない処理があるならif文を使う
  • コードの見やすさは、partialキーワードで担保
  • 汎用的なコンポーネントは○○Baseコンポーネントを使いまわし、プロジェクトごとに○○Baseを継承した○○コンポーネントを実装する

試行錯誤中なので、もしかしたら「この対策も欠陥がある」という可能性もありますが、少なくともコンポーネント指向の暴走は起こらなくなるでしょう。

しばらくこの方針で開発して、何かしらの変化があったらまた続きを書きます。

記事をシェアしてもらえると嬉しいです!