Help us understand the problem. What is going on with this article?

グローバルゲームジャムでクラス設計をやった話2017

More than 1 year has passed since last update.

お知らせ

2017/11/26開催の「プログラマのためのUnity勉強会」において、

Unity開発で使える設計の話+Zenjectの紹介

というタイトルで講演しました。こちらのスライドを先に見てから本記事を参照されることをおすすめします。


はじめに

去年に引き続き、今年もGGJに参加してきました。今回もそのことを書きたいと思います。

今回の内容は以前に投稿したUnity開発で便利だったアセット・サービス紹介 & Unityでのプログラミングテクニックとつながりがあるので、こちらを先に読んでからのほうがわかりやすいかもしれません。

Global Game Jam とは

GGJとは全世界同時に行われるゲームジャムのことです。要する、世界規模のゲーム開発ハッカソンです.
プログラマ、デザイナ、プランナ、グラフィッカなど様々な役職の人をごちゃまぜに、3~8人程度のチームを組み、48時間でゲームを1つ作ろうというイベントです。(前回のコピペ)

今回は茅場町会場にてプログラマと参加しました。

作ったゲーム

今年のテーマは「WAVES」でした。どういう経緯でこうなったのかは忘れたのですが、「マッチョが衝撃波で相手を押し出すゲームにしよう」と決まり、次のゲームを作成しました。

muscledrop____________.png

タイトル「Muscle Drop」
ルール「ヒップドロップで衝撃波を出して、相手を押し出したら点数になる。制限時間で一番点数を稼いだ人の勝ち。」

動画

使ったもの

フレームワークなど

スクリプト系アセット

メンバー

  • プログラマ4名
  • プランナー2名
  • グラフィッカー2名

今回も去年に引き続きプログラマが多いチームだったため、先に設計を固めて分担作業をするというスタイルで開発することにしました。今回もこれが大成功し、適切に作業分担できコンフリクトなく完成まで持っていくことができました。

できあがったソースコード

github: MuscleDropSrc

クラス設計2017

というわけで、今年はどのようなクラス設計をしたのか。また、どうしてそういう設計になったのかを紹介したいと思います。

1.与えられた要件

企画段階で決まったこと及び、プランナから提示された要求は次のようなものでした。

  • 4人対戦するゲーム
  • ジャンプ中にヒップドロップすることで、地面に衝撃波が発生する敵の衝撃波にあたると吹っ飛ぶ
  • 穴に落ちたり、トゲに当たると死亡する
  • 試合は制限時間式で、死んでも何回もリスポーン出来る
  • 誰が誰を倒したのかを記録してリザルトで集計する
  • アイテムがフィールド上にあり、拾うことでプレイヤのパラメータが変化する
  • ゲーム中に出したいギミックやアイテムは次の画像の通りで、上から優先度が高い
  • 特に衝撃波を伝播するギミックを是非やりたい

img_20170120_233214_1024.jpg
(プランナから与えられた要望。上のものから優先度が高い。)

img_20170122_192019_1024.jpg
(衝撃波を伝播するギミック案)

企画が決定したのが金曜日の22時ごろでした。この日は帰宅し、土曜日の朝からは早速コーディング作業に入りたかったため、数時間でクラス設計を終わらせる必要がありました。そのためこの段階でやりたいことをとにかく列挙して出し尽くしてもらい、後から大きく仕様を追加するようなことは避けてもらうことにしました。仕様を後から削るのは簡単ですけど、追加するのは相当厳しいですからね。

[追記]

同じチームだったプランナのzeal404さんが今回の仕様書の作成の仕方について記事を書いてくれました!
Global Game Jam 2017に参加してきた話

2.設計指針

  • 分担作業をしやすくするために必要なクラスは全て列挙する
  • 1コンポーネントあたりの責務を1つに限定するルールを徹底する(単一責任原則)
  • コンポーネントの関係性さえ綺麗に保たれているなら、コンポーネント内の実装はどれだけ汚くなっても構わない
  • 事故を減らすために、データを扱う型はイミュータブルな構造体として扱う
  • コンポーネントはUniRxを用いたObserverパターンでReacitveに駆動するようにする

3.完成したクラス図

クラス図

image

詳細に書くと時間が足りなかったため重要な部分だけ書き、後は口頭で伝える想定のためおそらく”完璧な”UML図ではないです。実線黒矢印が「参照」、白抜き矢印が「実装・継承」、破線矢印が「利用する」、という意味で書きました。(今思うと破線と実線の使い方逆…?)

また、ここに書いてあるクラスは全てMonoBehaviourを継承しています。

puml
@startuml

namespace Attacks{
    interface IAttacker

    namespace AttckerImpls{

        class PlayerAttacker <<struct>>{
         + PlayerLogic.PlayerId PlayerId
        }

        class NonPlayerAttacker <<struct>>

        PlayerAttacker --r|> Attacks.IAttacker
        NonPlayerAttacker --r|> Attacks.IAttacker
    }

    abstract class BaseBullet{
      + IAttacker attcker
      # float damagePower
    }
    BaseBullet -> IAttacker
    BaseBullet ..> Damages.Damage
    BaseBullet ..> Damages.IDamageApplicable
    BaseBullet ..> Damages.IDieable
    namespace BulletImpls{
        class ShockWaveBullet{
          - vector3 velocity
        }

        class HipDropBullet{
        }

        class FieldGimmickBullet {
        }

        HipDropBullet --|> Attacks.BaseBullet
        ShockWaveBullet --|> Attacks.BaseBullet
        FieldGimmickBullet --|> Attacks.BaseBullet
    }
}

namespace Damages{
    interface IDamageApplicable{
    + void ApplyDamage(Damage damage)
    }

    IDamageApplicable --l> Damage

    class Damage<<struct>>{
      + IAttacker attacker
      + float value
      + vector3 direction
    }

    Damage -> Attacks.IAttacker

    interface IDieable{
        + void Kill()
    }

}
namespace Items{
    enum ItemType
    abstract class ItemBase{
        + ItemType
    }
    ItemBase -> ItemType
    abstract class FixedItem
    FixedItem --u|> ItemBase

    namespace ItemImpls{
        class ProteinItem
        ProteinItem --u|> Items.FixedItem
    }
}

namespace Players{

    interface IGameStatusReadable{
        + ReactiveProperty<GameState> gameStage
    }

    enum PlayerId

    class PlayerParameters<<struct>>{
        + float JumpPower
        + float HipDropScale
        + float MoveSpeed
        + float HipDropSpeed
        + float FuttobiRate
        + float HipDropPower
        + float HipDropDamping
    }

    PlayerCore *- PlayerParameters

    class PlayerCore{
      + PlayerId id
      + IAttacker latestDamageAttacker
      + BoolReactiveProperty IsAlive
      + IObservable<PlayerId,DeadReason> OnPlayerDead
      + IObservable<Damage> OnDamaged
      + IObservable<PlayerId> OnInitializeAsync
      + IObservable<ItemType> OnPickUpItem
      - PlayerParameters DefaultPlayerParams
      + ReactiveProperty<PlayerParameters> CurrentPlayerParams

      + void ApplyDamage(Damage damage)
      + void Kill()

    }
    PlayerCore --> PlayerId
    PlayerCore ..> Items.ItemBase
    PlayerCore ..> IGameStatusReadable

    abstract class BasePlayerComponent{
        # void OnInitialize
    }

    PlayerCore <-- BasePlayerComponent

    interface IInputEventProvider{
        + BoolReactiveProperty JumpButton
        + BoolReactiveProperty AttackButton
        + ReactiveProperty<Vector3> MoveDirection
    }

    BasePlayerComponent ---u> IInputEventProvider

    namespace InputImpls{
        class DebugKeyInputEventProvider
        class MultiPlayerInputEventProvider{
            - PlayerId id
        }
        class AiInputEventProvider

        DebugKeyInputEventProvider --|> Players.IInputEventProvider
        MultiPlayerInputEventProvider --|> Players.IInputEventProvider
        MultiPlayerInputEventProvider ..> Players.PlayerCore :自分のIDを取得する
        AiInputEventProvider --|> Players.IInputEventProvider
    }


    class PlayerMover {
       + BoolReactiveProperty IsRuning
       + BoolReactiveProperty IsOnAir
    }

    PlayerMover ---|> BasePlayerComponent
    PlayerMover --> PlayerCharacterController
    PlayerMover --> PlayerWeapon

    class PlayerAnimator
    PlayerAnimator ---u|> BasePlayerComponent
    PlayerAnimator --> PlayerWeapon

    class PlayerCharacterController{
        + BoolReactiveProperty IsGrounded
    }
    PlayerCharacterController --u|> BasePlayerComponent
    class PlayerWeapon {
        + PlayerAttacker playerAttacker
        + BoolReactiveProperty IsAttacking
    }
    PlayerWeapon --r|> BasePlayerComponent
    PlayerWeapon --> PlayerCharacterController

    class PlayerEffectEmitter
    PlayerEffectEmitter --u|> BasePlayerComponent

    PlayerMover --u> PlayerCore
    PlayerAnimator --u> PlayerCore
    PlayerAnimator --u> PlayerMover
    PlayerCore --|>  Damages.IDamageApplicable
    PlayerCore --|>  Damages.IDieable
}

namespace GameManagers {
    abstract class BaseGameManager -|> Players.IGameStatusReadable
    class MainGameManager --u|> BaseGameManager
    class DebugGameManager --u|> BaseGameManager

    BaseGameManager --u> PlayerProvider
    BaseGameManager --u> GameTimeManager
    BaseGameManager ..> Players.PlayerCore : 監視

    class PlayerProvider
    class GameTimeManager

    enum GameState{
        + Initializing
        + Ready
        + Battle
        + Result
        + Finished
    }
}

namespace StageGimmicks{
    class Nanka
}

@enduml

シーケンス図

Sequence.png

@startuml

participant GameManager as gm
participant PlayerProvider as pp
participant PlayerCore as pc
participant TimerManager as tm

== State:Initializing ==

gm -> pp : 初期化依頼

loop 人数分繰り返す
pp -> pc: Player生成(PlayerId発行)
pc -> pc : 初期化
pc --> pp : 初期完了
pp --> gm : 生成したPlayer
gm -> pc :監視開始
pc ->> gm : 監視開始
end

gm -> gm : Readyへ移行
gm ->> pc : Ready

== State:Ready ==

gm -> tm : Readyカウントダウンタイマ起動&監視開始
activate tm #FFBBBB
tm -> tm :カウントダウン(3,2,1,開始!)
tm ->> gm : Readyカウント終了
deactivate tm

gm -> gm : Battleへ移行
gm ->> pc : Battle開始

== Battle ==

gm -> tm : メインタイマ起動&監視開始
activate tm #FFBBBB

loop
pc -> pc : 死亡
pc ->> gm :死亡通知
gm -> gm : スコア更新
pc -> pc : 一定時間後に復活
end

tm ->> gm : メインタイマ終了
deactivate tm

gm -> gm : Resultへ移行
gm ->> pc : 試合終了

== Result ==

gm -> gm : 終了結果表示とか

gm -> gm : Finishedへ移行

== Finished ==

gm -> gm :シーン遷移

@enduml

あとは実装時に混乱しないために、先にマネージャ同士の処理順序をシーケンス図に起しておくことにしました。実装もだいたいこの通りになりました。

4.設計作業の順序

Ⅰ.要素の洗い出し

まず設計を行うにあたり、「何を作る必要であるか?」を洗い出す必要があります。
そのためにも金曜日の24時ごろまでに実装したい要件を全部プランナに列挙してもらい、それを元に何が必要であるかを考えていきます。

まず今回はプレイヤ同士が戦うゲームのため、「Player」という概念が必要になるということがわかります。次に、「相手をふっ飛ばす」という点から「Damage」という概念が。「誰が誰を倒したか?」という情報を管理するために「Attacker」という概念が。「衝撃波を飛ばす」という点から攻撃そのものを表す「Bullet」という概念。アイテムを拾うという点から「Item」が。ゲームの進行を管理するという点から「GameManager」という概念がそれぞれ必要になるなと連想しました。

これらをまずはざっと図に起こし、ここから肉付けしていくことにしました。

1.png
(とりあえず名前空間だけ切ったクラス図)

Ⅱ.Attacker周りを埋める

いきなり全体を書こうとするとわけがわからなくなるので、仕様がはっきりしているところから順番に埋めていきます。
まずは攻撃者情報を表す「Attacker」および、実際に飛んでいく当たり判定を管理する「Bullet」周りを埋めます。

Attacker

Attackerは「この攻撃の主因は何なのか?」を表す概念として定義します。コード上では「IAttackerインターフェイス」として取り回すことにしました。実装としては、プレイヤの攻撃を表す「PlayerAttacker構造体」と、フィールド上のギミックや自殺した時に利用される「NonPlayerAttacker構造体」の2つを用意しました。

image
(PlayerAttackerは内部に誰からの攻撃であるかを示すPlayerIdを持つ)

Attacker
namespace Assets.MuscleDrop.Scripts.Attacks
{
    public interface IAttacker
    {

    }
}

namespace Assets.MuscleDrop.Scripts.Attacks.AttackerImpls
{
    /// <summary>
    /// プレイヤからの攻撃を表す
    /// </summary>
    public struct PlayerAttacker : IAttacker
    {
        public PlayerId PlayerId { get; private set; }

        public PlayerAttacker(PlayerId playerId) : this()
        {
            PlayerId = playerId;
        }
    }

    /// <summary>
    /// フィールド上のギミックからの攻撃を表す
    /// </summary>
    public struct NonPlayerAttacker : IAttacker
    {
        public static NonPlayerAttacker Default = new NonPlayerAttacker();
    }
}


Bullet

続いて、当たり判定を管理する「Bullet」を定義します。今回はプレイヤの出す攻撃もフィールド上の攻撃も全て一緒くたにBulletとして扱い、その差はAttackerを内部に持つことで表現することにします。

また、Bulletは派生することが最初からわかりきっているため、抽象化してBaseBulletというabstractな基底クラスを用意して共通して使いそうなパラメータを基底にまとめておきます。

image
(BaseBulletという基底クラスを作り、Attackerを利用して何の攻撃であるかを表現する)

BaseBullet
namespace Assets.MuscleDrop.Scripts.Attacks
{
    public abstract class BaseBullet : MonoBehaviour
    {
        public IAttacker Attacker { get; set; }

        protected float DamagePower { get; set; }
    }
}

Ⅲ.Damage周りを埋める

攻撃関係の概念の定義ができたので、次に「攻撃されたこと」を扱うためのオブジェクトとしてDamage周りを埋めることにします。

Damageオブジェクト

実際に「誰から攻撃を受けたのか」「どれだけのダメージ値を受けたのか」を受けたわすためのオブジェクトとして、Damage構造体を定義します。こういった「値」を表現するだけのオブジェクトは構造体として作ったほうが取り回しが良いので構造体(struct)にしています。

image
(Damage構造体を追加。内部にIAttacker、ダメージ値、吹っ飛ぶ方向を持つ。これをBaseBalletが利用する。)

Damage
namespace Assets.MuscleDrop.Scripts.Damages
{
    [Serializable]
    public struct Damage
    {
        /// <summary>
        /// 攻撃者
        /// </summary>
        public IAttacker Attacker;

        /// <summary>
        /// ダメージ値
        /// </summary>
        public float Value;

        /// <summary>
        /// 吹っ飛ばす向き
        /// </summary>
        public Vector3 Direction;
    }
}

IDamageApplicableインターフェイス

次に、このDamageオブジェクトを実際にPlayerなどの相手に伝えるためのインターフェイスとして「IDamageApplicableインターフェイス」を定義します。Bulletは衝突時にIDamageApplicableをGetComponentし、存在するならIDamageApplicable.ApplyDamage(Damage damage)を呼び出すという実装にします。
また、触れたら即死の「トゲ」ギミックなどがあったのでついでに「IDieableインターフェイス」も定義し、Kill()を呼び出せばプレイヤを即死させることができるようにもしておきます。

image
(Bulletが各インターフェイスを利用して相手にダメージを与える)

IDamageApplicable
namespace Assets.MuscleDrop.Scripts.Damages
{
    public interface IDamageApplicable
    {
        void ApplyDamage(Damage damage);
    }
}

Ⅳ.アイテム周りを埋める

攻撃周りは一旦これで良しとして、次にアイテム周りを埋めます。

基本はさっきのBullet周りと同じノリで、アイテム種別を表す「ItemType」を定義してそれを扱う「ItemBase」を定義します。

image

また、アイテムの種類がいろいろ増えそうなことを見込んで一度FixedItem(フィールドに固定で配置されるアイテムを想定した)基底を挟み、アイテムを定義しておきます。

なお、ItemについてはIPickableUpみたいなインターフェイスは用意していません。理由としては、Itemはあくまでプレイヤしか拾う対象が居ないため、Playerが直接衝突時に「Itemであるか?」を調べればいいだけでインターフェイスがいらないと判断したためです。

Ⅴ. Player周りを埋める

Player周りは複雑になるので、順に解説していきます。

パラメータ

まずはPlayerで利用するパラメータを扱うものを先に定義してしまいます。今回はPlayerIdとPlayerParametersという2つを用意しました。

image
(PlayerParametersがプレイヤの現在のステータスを表現する)

Playerのパラメータ
namespace Assets.MuscleDrop.Scripts.Players
{
    public enum PlayerId
    {
        Player1 = 1,
        Player2 = 2,
        Player3 = 3,
        Player4 = 4
    }

    static class PlayerIdExtensions
    {
        public static Color ToColor(this PlayerId id)
        {
            switch (id)
            {
                case PlayerId.Player1:
                    return Color.red;
                case PlayerId.Player2:
                    return Color.blue;
                case PlayerId.Player3:
                    return Color.green;
                case PlayerId.Player4:
                    return Color.yellow;
                default:
                    throw new ArgumentOutOfRangeException("id", id, null);
            }
        }

        public static string ToName(this PlayerId id)
        {
            switch (id)
            {
                case PlayerId.Player1:
                    return "1P";
                case PlayerId.Player2:
                    return "2P";
                case PlayerId.Player3:
                    return "3P";
                case PlayerId.Player4:
                    return "4P";
                default:
                    throw new ArgumentOutOfRangeException("id", id, null);
            }
        }
    }

    [Serializable]
    public struct PlayerParameters
    {
        /// <summary>
        /// ジャンプ力
        /// </summary>
        public float JumpPower;

        /// <summary>
        /// 移動速度
        /// </summary>
        public float MoveSpeed;

        /// <summary>
        /// 吹っ飛びやすさ
        /// </summary>
        public float FuttobiRate;

        /// <summary>
        /// ヒップドロップの範囲
        /// </summary>
        public float HipDropScale;

        /// <summary>
        /// ヒップドロップの伝播速度
        /// </summary>
        public float HipDropSpeed;

        /// <summary>
        /// ヒップドロップの吹っ飛び力
        /// </summary>
        public float HipDropPower;

        /// <summary>
        /// ヒップドロップの距離減衰率
        /// </summary>
        public float HipDropDamping;
    }
}

Input周り

続いて、キーボードやコントローラからの入力を受け付けてイベントを発行する「IInputEventProvider」定義します。Input周りは一度抽象化を挟んだほうがデバッグがやりやすいという経験から、Inputは抽象化することにしています。こうすることで、「固定のキー配置からの入力を受け付けるInput」や「複数人プレイ時にジョイパッドのIDを見て入力を受け付けるInput」を簡単に差し替えることができ、開発作業がやりやすくなります。

image

IInputEventProviderは入力イベントをUniRxのReactivePropertyとして外に公開する実装になっています。

IInputEventProvider
namespace Assets.MuscleDrop.Scripts.Players.Inputs
{
    public interface IInputEventProvider
    {
        IReadOnlyReactiveProperty<bool> JumpButton { get; }
        IReadOnlyReactiveProperty<bool> AttackButton { get; }
        IReadOnlyReactiveProperty<Vector3> MoveDirection { get; }
    }
}
DebugInputEventProviderの実装例
namespace Assets.MuscleDrop.Scripts.Players.Inputs
{
    /// <summary>
    /// キーボードからのInputEventを発行する
    /// </summary>
    public class DebugInputEventProvider : BasePlayerComponent, IInputEventProvider
    {

        private ReactiveProperty<bool> _attack = new BoolReactiveProperty();
        private ReactiveProperty<bool> _jump = new BoolReactiveProperty();
        private ReactiveProperty<Vector3> _moveDirection = new ReactiveProperty<Vector3>();
        public IReadOnlyReactiveProperty<bool> AttackButton { get { return _attack; } }
        public IReadOnlyReactiveProperty<Vector3> MoveDirection { get { return _moveDirection; } }
        public IReadOnlyReactiveProperty<bool> JumpButton { get { return _jump; } }


        protected override void OnInitialize()
        {
            this.UpdateAsObservable()
                .Select(_ => Input.GetKey(KeyCode.Z))
                .DistinctUntilChanged()
                .Subscribe(x => _jump.Value = x);

            this.UpdateAsObservable()
                .Select(_ => Input.GetKey(KeyCode.X))
                .DistinctUntilChanged()
                .Subscribe(x => _attack.Value = x);

            this.UpdateAsObservable()
                .Select(_ => new Vector3(Input.GetAxis("Horizontal1"), 0, Input.GetAxis("Vertical1")))
                .Subscribe(x => _moveDirection.SetValueAndForceNotify(x));
        }
    }
}

本体(Core)

次にPlayerの中核となるPlayerCoreを定義します。PlayerCoreは「プレイヤのパラメータや状態を保持して外部に公開したり、外部からのイベントを受けて内部のコンポーネントに伝達する存在」として定義します。

また、Playerに貼り付けるコンポーネントはこのCoreに強く依存するため、Coreを参照しやすくするために基底クラスBasePlayerComponentも定義しておきます。また、入力イベントも各コンポーネントで利用するためIInputEventProviderへの参照も持たせておきます。

image

(BasePlayerComponentはCoreを参照し、各種イベントやパラメータの変化を他のコンポーネントで受け取りやすくする)


各種コンポーネントを用意する

Unity開発で便利だったアセット・サービス紹介 & Unityでのプログラミングテクニックでも説明したのですが、自分はとにかく責務に応じてコンポーネントを細かく分割するという手法を取っています。
今回もその考えに則って、以下のコンポーネントを定義します。

  • 移動処理の管理:PlayerMover
  • RigidBodyのラッパー:PlayerCharacterController
  • 攻撃処理:PlayerWeapon
  • アニメーション管理:PlayerAnimator
  • エフェクト再生:PlayerEffectEmitter

これらコンポーネントは先程のBasePlayerComponentを継承させておきます。

image


コンポーネント間の関係性を考えて参照を定義する

次に、コンポーネント間にどういう関係性があるかを考えて参照を書いていきます。
ここで注意する点としては、「循環参照を避ける」です。循環参照が発生すると初期化が困難になったり、動作の確認が複雑化するのでできるだけ避けるようにしましょう。循環参照が発生しそうな場合はコールバックを使うなどしてできるだけ依存性が一方向で済むようにがんばります。

image
(各コンポーネントの依存関係を定義していく。ここまで複雑になるとPlantUMLでは限界が出て来る)


先程のDamage、Itemとくっつける

次に、先程のDamages/Itemsパッケージと結びつけて合体させます。
image

すごいことになってきましたが、先程のDamagesで定義したIDamageApplicableIDieableインターフェイスをPlayerCoreが実装し、ItemBaseへの参照を追加しただけです。

マネージャ周りを定義する

定義

次に、ゲームの進行を管理するためのマネージャを定義します。

image

デバッグのために必要かな?と思ってBaseGameManagerを一度挟みましたけど、結果としてはこれは不要でした。


PlayerCoreと紐付ける

次にGameManagerとPlayerCoreを紐付けることにしました。

が、ここで1つ問題が発生しました。点数を管理するためにはManagerはPlayerCoreを参照して監視する必要があります。その一方で、ゲームの状態(試合開始・試合中・終了)を判定して制御するためにはPlayerCoreはManagerを監視する必要があります。ManagerがPlayerCoreのパラメータを適宜変更するという設計でも良かったのですが、今回は妥協して循環参照させる方向にしました。といっても、直接に相互参照はさせず、Player側にIGameStatusReadableインターフェイスを定義して一段クッションを挟むことにしています。(こうすることでDebug時にMockに差し替えることが出来るため)

image
(GameManagerとPlayerCoreを参照させあう)

最後に

PlantUMLの記述を整えて、メモ書きを追加して完成です。

image

5.実装する

設計が終わったらあとはひたすら実装するだけです。

ソースコード

今回できあがったスクリプトはgithubに公開したため、ぜひ参考にして下さい。
(実装中に設計が微妙に変わっていった部分もあるのでクラス図の通りになっていない部分もあります)

github: MuscleDropSrc

感想

GGJについての感想

楽しく開発できて、完成もして良かったです。ただ、企画時に自分の得意なゲームジャンルに誘導してしてしまった節があるのでそこが反省点です…。また、自分の持っているノウハウやリソースを突っ込んだせいですごい「どこかで見たことある」見栄えになってしまったので、ちょっとそこも反省点です…。

設計についての感想

クラス設計は正解がなく、何が正しい設計なのか正直自分にもわかりません。今回の設計についてももっと良い設計はあったかもしれません。

だからといって設計せずに行き当たりばったりに作るよりかは設計したほうが100倍マシなので、ぜひ皆さんコーディングに入る前に自信がなくても設計をしてみることをオススメします。


おまけ1. ObserverパターンでReactiveにコンポーネントを繋げる

今回の開発ではObserverパターンを用いてコンポーネント同士が連携する設計にしました。
わかりやすく言えば、「何か起きた時にイベントを発行し、そのイベントに興味があるコンポーネントのみがそれを購読して自分が担当する処理をする」という作りにしてあります。

例:ダメージ処理

例として、ダメージを受けた時の挙動をどう実装したかを解説します。

イベント発行側

まず先程も説明しましたが、"PlayerCore"がIDamageApplicableを実装しており、Bulletがこれを呼び出す作りになっています。

PlayerCore(Damage周りのみ抜粋)
using System;
using Assets.MuscleDrop.Scripts.Attacks;
using Assets.MuscleDrop.Scripts.Attacks.AttackerImpls;
using Assets.MuscleDrop.Scripts.Damages;
using Assets.MuscleDrop.Scripts.GamaManagers;
using Assets.MuscleDrop.Scripts.Items;
using Assets.MuscleDrop.Scripts.Utilities.ExtensionMethods;
using UniRx;
using UniRx.Triggers;
using UnityEngine;
using Zenject.SpaceFighter;

namespace Assets.MuscleDrop.Scripts.Players
{
    public class PlayerCore : MonoBehaviour, IDamageApplicable, IDieable
    {

        private Subject<Damage> _damageSubject = new Subject<Damage>();

        /// <summary>
        /// ダメージを受けたことを通知する
        /// </summary>
        public IObservable<Damage> OnDamaged { get { return _damageSubject; } }

        /// <summary>
        /// ダメージを与える(Bulletが呼び出す)
        /// </summary>
        public void ApplyDamage(Damage damage)
        {
            _damageSubject.OnNext(damage);
        }

        //Damage処理以外は省略
    }
}

PlyaerCoreはIObservable<Damage> OnDamagedとして「ダメージを受けたこと」を通知するObservableを公開し、実際にApplyDamageが実行されたらこのObservableを通じて各コンポーネントにダメージを受けたという通知を行います。

イベント購読側

あとはplayerCore.OnDamagedを購読していればプレイヤがダメージを受けたことを検知できるため、このイベントを使って各コンポーネントが自分の責務に応じた処理を行います。

PlayerMover

PlayerMoverの責務は「移動」なので、PlayerMoverはダメージを受けた時に吹っ飛ばすという処理のみを行います。

移動処理(Damage周りのみ抜粋)
[RequireComponent(typeof(PlayerCharcterController))]
public class PlayerMover : BasePlayerComponent
{
    private PlayerCharcterController cc;
    protected override void OnInitialize()
    {
        cc = GetComponent<PlayerCharcterController>();

        // ダメージイベントが来たらプレイヤを吹っ飛ばす
        Core.OnDamaged.Subscribe(x =>
        {
            var force = x.Direction * x.Value * CurrentPlayerParameter.Value.FuttobiRate;
            cc.ApplyForce(force);
        });


        //以下略
    }
}

PlayerEffect (EffectEmmiter)

PlayerEffectの責務は「パーティクルエフェクトの再生」なので、ダメージを受けた時に該当するパーティクルエフェクトを再生します。

PlayerEffect
public class PlayerEffect : BasePlayerComponent
{
    [SerializeField]
    private GameObject _damageEffectGO;
    [SerializeField]
    private GameObject _deadEffectGO;

    protected override void OnInitialize()
    {
        Core.OnDamaged.Subscribe(x => 
            {
                _damageEffectGO.SetActive(true);
            });

        Core.OnDead.Subscribe(x => 
            {
                _deadEffectGO.SetActive(true);
            });
    }
}

PlayerSound

PlayerEffectの責務は「効果音の再生」なので、ダメージを受けた時に該当する効果音を再生します。

PlayerSound
public class PlayerSound : BasePlayerComponent
{
    [SerializeField]
    private SoundEffect[] damageSounds;


    protected override void OnInitialize()
    {
        if (damageSounds.Length != 0)
        {

            Core.OnDamaged
                .ThrottleFirst(TimeSpan.FromSeconds(1))
                .Subscribe(_ =>
            {
                var s = damageSounds[Random.Range(0, damageSounds.Length)];
                AudioManager.PlaySoundEffect(s);
            });
        }
    }
}

この手法のメリット

  • 単一責任原則を守りやすい

1つ1つのコンポーネントの仕事内容が明確になり、「どこに何を書けばいいんだっけ?」「どこに何を書いったっけ?」という迷いが無くなります。

  • 分担作業しやすい

イベント周りのインターフェイスさえ先に決めておけば、「AさんはPlayerMoverを、BさんはPlayerAnimatorをそれぞれ責任を持って書く」といった様に分担作業がやりやすくなります。またコンポーネントの粒度が適切に区切られていれば、1つのコンポーネントを同時に複数人の人が触る可能性が減りコンフリクトが発生するリスクを下げることができます。

  • 自分の責務以外については無責任に書ける

例えば「PlayerMoverは移動については責任をもって処理するが、それ以外のことについては知らない。そっちで勝手にやれ。」といった様に、自分の興味のない仕事については全くの無責任でいて問題がありません。

具体的な例は次のコードです

PlayerMover
[RequireComponent(typeof(PlayerCharcterController))]
public class PlayerMover : BasePlayerComponent
{
    Subject<Unit> _hipDroppedSubject = new Subject<Unit>();

    private PlayerCharcterController cc;

    /// <summary>
    /// ヒップドロップ中か?
    /// </summary>
    public IReadOnlyReactiveProperty<bool> IsHipDroping { get { return _isHipDroping; } }

    /// <summary>
    /// ヒップドロップが完了した
    /// </summary>
    public IObservable<Unit> OnHipDropped { get { return _hipDroppedSubject; } }

    protected override void OnInitialize()
    {
        cc = GetComponent<PlayerCharcterController>();

        // 着地処理
        cc.IsGrounded
            .Where(x => x && !isFreezed)
            .Subscribe(x =>
            {
                if (_isHipDroping.Value)
                {
                    _hipDroppedSubject.OnNext(Unit.Default);
                }
            });

    //ジャンプ処理とかいろいろあるけどとりあえず省略
    }
}

いろいろ端折ってますが、注目して頂きたい点はPlayerMoverはヒップドロップ中フラグについては責任をもって管理するが、それが他のコンポーネントからどういう使われ方をするか一切知らなくて良いというところです。

こうすることで、実装中に余計な考えを一切排除し、そのコンポーネントの責務となる処理のみをただ記述すれば良くなります。

この手法のデメリット

このコンポーネントを分割してイベント連携する手法、メリットもありますがデメリットもあります。
それは「複数のコンポーネントに複雑にまたがった処理が非常に書きにくい」というところです。

例えば、「アニメーションに合わせて攻撃を当てるとコンボが繋がり、プレイヤが敵を自動追尾しながら連続攻撃ができる」みたいな機能を実装しようとすると、この手法ではイベントが大量に必要になってしまいます。

そのため、プレイヤの動作が複雑である場合はこの方法は通用しないかもしれません。

おまけ2. Zenject使ってみた

今回の開発では試しにDIフレームワークとしてZenjectを取り入れてみました。といっても凝った使い方はしておらず今回はScene Bindingsという機能に絞って利用していました。

Scene Bindingsはいわば「シーン中に配置されたコンポーネント同士の参照関係を自動解決する機構」です。Managerへの参照を取得しやすくするためにManagerをシングルトンにして var manager = xxxx.Instance; みたいな書き方をすることが多いと思いますが、Scene Bindingsを使うとManagerを無駄にシングルトン化せずに、簡単に参照を取得できるようになります。

Scene Bindingsの使い方

1. Scene Contextをシーンに配置する

image
[GameObject] -> [Zenject] -> [Scene Context]からSceneContextを生成してシーンに配置する

2. 参照を取得される側のコンポーネントと同じGameObjectにZenjectBindingをアタッチし、対象のコンポーネントを登録する

image

3. 参照を取得する側で対象のコンポーネントを定義し、[Inject]アトリビュートを追加する

using Assets.MuscleDrop.Scripts.Audio;
using UnityEngine;
using Zenject;
using UniRx;
namespace Assets.MuscleDrop.Scripts.GamaManagers
{
    /// <summary>
    /// 試合の進行に合わせてSEを再生する
    /// </summary>
    public class BattleSoundManager : MonoBehaviour
    {
        [Inject]
        private GameTimeManager timerManager;

        [Inject]
        private ResultManager resultManager;

        [Inject]
        private MainGameManager gameManager;

        void Start()
        {
            //以下略
        }

以上です。これだけで、[Inject]アトリビュートで指定した変数に実行時に自動的にシーン中のコンポーネントへの参照が代入され利用することができるようになります。めちゃくちゃ便利だったのでおすすめです。
なお、マルチシーンで利用する場合はDecorator Contextを利用することでInjectできるようになります。

ちなみに、SceneBindingsは動的に生成されたオブジェクトに対しては注入してくれないので、動的にオブジェクトを生成する場合はInstallerを用いてBindingルールを記述してあげる必要があります。

toRisouP
virtualcast
VRシステム(バーチャルキャスト)の開発、運営、企画
https://virtualcast.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした