はじめに
去年に引き続き、今年もGGJに参加してきました。今回もそのことを書きたいと思います。
今回の内容は以前に投稿したUnity開発で便利だったアセット・サービス紹介 & Unityでのプログラミングテクニックとつながりがあるので、こちらを先に読んでからのほうがわかりやすいかもしれません。
Global Game Jam とは
GGJとは全世界同時に行われるゲームジャムのことです。要する、世界規模のゲーム開発ハッカソンです.
プログラマ、デザイナ、プランナ、グラフィッカなど様々な役職の人をごちゃまぜに、3~8人程度のチームを組み、48時間でゲームを1つ作ろうというイベントです。(前回のコピペ)
今回は茅場町会場にてプログラマと参加しました。
作ったゲーム
今年のテーマは「WAVES」でした。どういう経緯でこうなったのかは忘れたのですが、「マッチョが衝撃波で相手を押し出すゲームにしよう」と決まり、次のゲームを作成しました。
タイトル「Muscle Drop」
ルール「ヒップドロップで衝撃波を出して、相手を押し出したら点数になる。制限時間で一番点数を稼いだ人の勝ち。」
使ったもの
フレームワークなど
スクリプト系アセット
メンバー
- プログラマ4名
- プランナー2名
- グラフィッカー2名
今回も去年に引き続きプログラマが多いチームだったため、先に設計を固めて分担作業をするというスタイルで開発することにしました。今回もこれが大成功し、適切に作業分担できコンフリクトなく完成まで持っていくことができました。
クラス設計2017
というわけで、今年はどのようなクラス設計をしたのか。また、どうしてそういう設計になったのかを紹介したいと思います。
1.与えられた要件
企画段階で決まったこと及び、プランナから提示された要求は次のようなものでした。
- 4人対戦するゲーム
- ジャンプ中にヒップドロップすることで、地面に衝撃波が発生する敵の衝撃波にあたると吹っ飛ぶ
- 穴に落ちたり、トゲに当たると死亡する
- 試合は制限時間式で、死んでも何回もリスポーン出来る
- 誰が誰を倒したのかを記録してリザルトで集計する
- アイテムがフィールド上にあり、拾うことでプレイヤのパラメータが変化する
- ゲーム中に出したいギミックやアイテムは次の画像の通りで、上から優先度が高い
- 特に衝撃波を伝播するギミックを是非やりたい
企画が決定したのが金曜日の22時ごろでした。この日は帰宅し、土曜日の朝からは早速コーディング作業に入りたかったため、数時間でクラス設計を終わらせる必要がありました。そのためこの段階でやりたいことをとにかく列挙して出し尽くしてもらい、後から大きく仕様を追加するようなことは避けてもらうことにしました。仕様を後から削るのは簡単ですけど、追加するのは相当厳しいですからね。
2.設計指針
- 分担作業をしやすくするために必要なクラスは全て列挙する
- 1コンポーネントあたりの責務を1つに限定するルールを徹底する(単一責任原則)
- コンポーネントの関係性さえ綺麗に保たれているなら、コンポーネント内の実装はどれだけ汚くなっても構わない
- 事故を減らすために、データを扱う型はイミュータブルな構造体として扱う
- コンポーネントはUniRxを用いたObserverパターンでReacitveに駆動するようにする
3.完成したクラス図
クラス図
詳細に書くと時間が足りなかったため重要な部分だけ書き、後は口頭で伝える想定のためおそらく”完璧な”UML図ではないです。実線黒矢印が「参照」、白抜き矢印が「実装・継承」、破線矢印が「利用する」、という意味で書きました。(今思うと破線と実線の使い方逆…?)
また、ここに書いてあるクラスは全てMonoBehaviourを継承しています。
シーケンス図
あとは実装時に混乱しないために、先にマネージャ同士の処理順序をシーケン図に起しておくことにしました。実装もだいたいこの通りになりました。
4.設計作業の順序
Ⅰ.要素の洗い出し
まず設計を行うにあたり、「何を作る必要であるか?」を洗い出す必要があります。
そのためにも金曜日の24時ごろまでに実装したい要件を全部プランナに列挙してもらい、それを元に何が必要であるかを考えていきます。
まず今回はプレイヤ同士が戦うゲームのため、「Player」という概念が必要になるということがわかります。次に、「相手をふっ飛ばす」という点から「Damage」という概念が。「誰が誰を倒したか?」という情報を管理するために「Attacker」という概念が。「衝撃波を飛ばす」という点から攻撃そのものを表す「Bullet」という概念。アイテムを拾うという点から「Item」が。ゲームの進行を管理するという点から「GameManager」という概念がそれぞれ必要になるなと連想しました。
これらをまずはざっと図に起こし、ここから肉付けしていくことにしました。
Ⅱ.Attacker周りを埋める
いきなり全体を書こうとするとわけがわからなくなるので、仕様がはっきりしているところから順番に埋めていきます。
まずは攻撃者情報を表す「Attacker」および、実際に飛んでいく当たり判定を管理する「Bullet」周りを埋めます。
Attacker
Attackerは「この攻撃の主因は何なのか?」を表す概念として定義します。コード上では「IAttackerインターフェイス」として取り回すことにしました。実装としては、プレイヤの攻撃を表す「PlayerAttacker構造体」と、フィールド上のギミックや自殺した時に利用される「NonPlayerAttacker構造体」の2つを用意しました。
(PlayerAttackerは内部に誰からの攻撃であるかを示すPlayerIdを持つ)
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な基底クラスを用意して共通して使いそうなパラメータを基底にまとめておきます。
(BaseBulletという基底クラスを作り、Attackerを利用して何の攻撃であるかを表現する)
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)にしています。
(Damage構造体を追加。内部にIAttacker、ダメージ値、吹っ飛ぶ方向を持つ。これをBaseBalletが利用する。)
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()
を呼び出せばプレイヤを即死させることができるようにもしておきます。
(Bulletが各インターフェイスを利用して相手にダメージを与える)
namespace Assets.MuscleDrop.Scripts.Damages
{
public interface IDamageApplicable
{
void ApplyDamage(Damage damage);
}
}
Ⅳ.アイテム周りを埋める
攻撃周りは一旦これで良しとして、次にアイテム周りを埋めます。
基本はさっきのBullet周りと同じノリで、アイテム種別を表す「ItemType」を定義してそれを扱う「ItemBase」を定義します。
また、アイテムの種類がいろいろ増えそうなことを見込んで一度FixedItem(フィールドに固定で配置されるアイテムを想定した)基底を挟み、アイテムを定義しておきます。
なお、ItemについてはIPickableUp
みたいなインターフェイスは用意していません。理由としては、Itemはあくまでプレイヤしか拾う対象が居ないため、Playerが直接衝突時に「Itemであるか?」を調べればいいだけでインターフェイスがいらないと判断したためです。
Ⅴ. Player周りを埋める
Player周りは複雑になるので、順に解説していきます。
パラメータ
まずはPlayerで利用するパラメータを扱うものを先に定義してしまいます。今回はPlayerIdとPlayerParametersという2つを用意しました。
(PlayerParametersがプレイヤの現在のステータスを表現する)
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」を簡単に差し替えることができ、開発作業がやりやすくなります。
IInputEventProviderは入力イベントをUniRxのReactivePropertyとして外に公開する実装になっています。
namespace Assets.MuscleDrop.Scripts.Players.Inputs
{
public interface IInputEventProvider
{
IReadOnlyReactiveProperty<bool> JumpButton { get; }
IReadOnlyReactiveProperty<bool> AttackButton { get; }
IReadOnlyReactiveProperty<Vector3> MoveDirection { get; }
}
}
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への参照も持たせておきます。
(BasePlayerComponentはCoreを参照し、各種イベントやパラメータの変化を他のコンポーネントで受け取りやすくする)
各種コンポーネントを用意する
Unity開発で便利だったアセット・サービス紹介 & Unityでのプログラミングテクニックでも説明したのですが、自分はとにかく責務に応じてコンポーネントを細かく分割するという手法を取っています。
今回もその考えに則って、以下のコンポーネントを定義します。
- 移動処理の管理:PlayerMover
- RigidBodyのラッパー:PlayerCharacterController
- 攻撃処理:PlayerWeapon
- アニメーション管理:PlayerAnimator
- エフェクト再生:PlayerEffectEmitter
これらコンポーネントは先程のBasePlayerComponentを継承させておきます。
コンポーネント間の関係性を考えて参照を定義する
次に、コンポーネント間にどういう関係性があるかを考えて参照を書いていきます。
ここで注意する点としては、「循環参照を避ける」です。循環参照が発生すると初期化が困難になったり、動作の確認が複雑化するのでできるだけ避けるようにしましょう。循環参照が発生しそうな場合はコールバックを使うなどしてできるだけ依存性が一方向で済むようにがんばります。
(各コンポーネントの依存関係を定義していく。ここまで複雑になるとPlantUMLでは限界が出て来る)
先程のDamage、Itemとくっつける
次に、先程のDamages/Itemsパッケージと結びつけて合体させます。
すごいことになってきましたが、先程のDamagesで定義したIDamageApplicable
とIDieable
インターフェイスをPlayerCoreが実装し、ItemBaseへの参照を追加しただけです。
マネージャ周りを定義する
定義
次に、ゲームの進行を管理するためのマネージャを定義します。
デバッグのために必要かな?と思ってBaseGameManager
を一度挟みましたけど、結果としてはこれは不要でした。
PlayerCoreと紐付ける
次にGameManagerとPlayerCoreを紐付けることにしました。
が、ここで1つ問題が発生しました。点数を管理するためにはManagerはPlayerCoreを参照して監視する必要があります。その一方で、ゲームの状態(試合開始・試合中・終了)を判定して制御するためにはPlayerCoreはManagerを監視する必要があります。ManagerがPlayerCoreのパラメータを適宜変更するという設計でも良かったのですが、今回は妥協して循環参照させる方向にしました。といっても、直接に相互参照はさせず、Player側にIGameStatusReadableインターフェイスを定義して一段クッションを挟むことにしています。(こうすることでDebug時にMockに差し替えることが出来るため)
(GameManagerとPlayerCoreを参照させあう)
最後に
PlantUMLの記述を整えて、メモ書きを追加して完成です。
5.実装する
設計が終わったらあとはひたすら実装するだけです。
ソースコード
今回できあがったスクリプトはgithubに公開したため、ぜひ参考にして下さい。
(実装中に設計が微妙に変わっていった部分もあるのでクラス図の通りになっていない部分もあります)
github: MuscleDropSrc
感想
GGJについての感想
楽しく開発できて、完成もして良かったです。ただ、企画時に自分の得意なゲームジャンルに誘導してしてしまった節があるのでそこが反省点です…。また、自分の持っているノウハウやリソースを突っ込んだせいですごい「どこかで見たことある」見栄えになってしまったので、ちょっとそこも反省点です…。
設計についての感想
クラス設計は正解がなく、何が正しい設計なのか正直自分にもわかりません。今回の設計についてももっと良い設計はあったかもしれません。
だからといって設計せずに行き当たりばったりに作るよりかは設計したほうが100倍マシなので、ぜひ皆さんコーディングに入る前に自信がなくても設計をしてみることをオススメします。
おまけ Zenject使ってみた
今回の開発では試しにDIフレームワークとしてZenjectを取り入れてみました。といっても凝った使い方はしておらず今回はScene Bindingsという機能に絞って利用していました。
Scene Bindingsはいわば「シーン中に配置されたコンポーネント同士の参照関係を自動解決する機構」です。Managerへの参照を取得しやすくするためにManagerをシングルトンにして var manager = xxxx.Instance;
みたいな書き方をすることが多いと思いますが、Scene Bindingsを使うとManagerを無駄にシングルトン化せずに、簡単に参照を取得できるようになります。
Scene Bindingsの使い方
1. Scene Contextをシーンに配置する
[GameObject] -> [Zenject] -> [Scene Context]からSceneContextを生成してシーンに配置する
2. 参照を取得される側のコンポーネントと同じGameObjectにZenjectBinding
をアタッチし、対象のコンポーネントを登録する
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できるようになります。