はじめに
この記事は サムザップ Advent Calendar 2021 の12/17の記事です。
昨日の記事は @K3nsuke さんによる「【Unity 2021】Visual ScriptingでChromeの恐竜ゲームを作る方法【初心者向け】」でした。
概要
ソーシャルゲームにおいても一般的となっているATB(アクティブタイムバトル)ですが、運用を続けていくとユーザの新しい体験を増やすためにマスタやアセットの更新である程度追加できるスキル以外にも、今までにない新しいルールを実装するケースがあります。
今回はそういったケースに耐えうる設計にリファクタリングをした話と、理想の設計を紹介できればと思います。
運用で考えられるパターン
基本的にゲーム制作は汎用的で、不具合を起こしにくく、影響範囲もすぐ分かる可読性の高い設計にしておく必要があります。
ATBの設計においてもそこは共通なので、ざっくりとどんなパターンが考えられるか洗い出してみます。
ゲームルールについて
- ストーリーと同期した通常のルール
- 素材調達系の常設ルール
- 期間限定イベントなどで他のユーザとスコアを競うランキング形式のルール
- 1、2年運用を続けた後アップデートで追加される可能性のある新ルール
- チュートリアル
- 他のユーザと一緒に行うマルチ形式のルール
- オートモード
などなど細かいものまで出せばキリがないですが少なくともこのあたりはどのゲームでもよくあると思います。
ルールだけで見ればこんなに考えないといけないのか、、と骨が折れますがどのルールでもコマンドボタンをタップしてスキルを出して敵を倒し、報酬をもらうという流れは共通していることがほとんどです。
仕様上将来的にそういった共通部分も破綻しないのであれば一旦はゲームルールごとのメインループやUIなどの出し分けができていれば良さそうですね。
Step0
といっても、最初から全てに対応させているとかなりコストがかかってしまうので、一番最初はモック開発として
ストーリーと同期した通常のルール
こちらのみを考えた開発をリリース前の段階で行うと思います。
ただここで大事なのが、コマンドを押して戦う流れは共通であるということです。
つまり、そこをゲームルールに依存しないコード設計にしておけば今後のルール実装の影響範囲として考える必要はなくなります。
Step1
ここから、
ストーリーと同期した通常のルール
素材調達系の常設ルール
期間限定イベントなどで他のユーザとスコアを競うランキング形式のルール
チュートリアル
このあたりのゲームルールに応じた設計を考えていきますが、ステップアップした設計をどんどん紹介していきたいので、まずはよくやりがちなコードから紹介していきます。
public class BattleManager
{
public void Initialize()
{
_timeLimit = 0;
switch (_battleRule)
{
case RuleType.MainRule:
_timeLimit = 60;
break;
case RuleType.ScoreChallengeRule:
_timeLimit = 30;
break;
}
_battleUI.Initialize();
}
}
public class BattleUI
{
public void Initialize()
{
switch (_battleRule)
{
case RuleType.MainRule:
_scoreText.gameObject.SetActive(false);
break;
case RuleType.ScoreChallengeRule:
_scoreText.gameObject.SetActive(true);
break;
}
}
}
switch文でルールごとに時間を設定したりUIの表示/非表示を出し分けたりしているコードですね
これだと、特定のルールでしか使わないオブジェクトを共通で持ってしまっていたり、ルールが増えるたびにBattleManagerやBattleUIクラスが膨らんでしまい、えげつないコード量になってしまいます。
Step2
さすがにバトル全体を管理しているクラスで全ての情報を持つのはいけてないので、それぞれ必要なデータをルール別のクラスで持つようにします。
public class BattleManager
{
public void Initialize()
{
switch (_battleRule)
{
case RuleType.MainRule:
_ruleData = new MainRuleData();
break;
case RuleType.ScoreChallengeRule:
_ruleData = new ScoreChallengeRuleData();
break;
}
_battleUI.Initialize();
}
}
public class BattleUI
{
public void Initialize()
{
switch (_battleRule)
{
case RuleType.MainRule:
_ruleUIData = new MainRuleUIData();
break;
case RuleType.ScoreChallengeRule:
_ruleUIData = new ScoreChallengeRuleUIData();
break;
}
}
}
※中のデータ設定処理は省略しています。
これだけでも、だいぶ全体の管理クラスはすっきりさせることができます。
しかし、特定のルールでしか使わないデータを使用する場合に結局条件分岐が必要となってしまいます。
Step3
今回リファクタリングを行って実装コストに削減できたのは、このStep3まで行ったことによるものです。
内容としては、特定のルールでしか使用できないデータはインタフェースで管理し、そのデータを使用するルールにのみ実装するということです。
public interface IScoreChallengeData
{
// スコアの上限値
int ScoreLimit { get; set; }
}
こうすることで、ルールの条件分岐を入れなくても、このインタフェースを実装していたらこの処理を行うといったコードにできるので、可読性や影響範囲の分かりやすさにも繋がります。
Step4
工数の関係上、全体管理クラスに依存したコードが多く断念しましたが、実際はここまでできたらよかったという内容になります。
Step3までにちゃんと考慮できてなさそうなのが、
1、2年運用を続けた後アップデートで追加される可能性のある新ルール
他のユーザと一緒に行うマルチ形式のルール
オートモード
この3点です。新ルールについては内容次第でStep3のものに追加するだけで対応できる可能性がありますが、他2つは処理の流れが根本から違う可能性があるため、条件分岐が残っていたり別クラスに同じような内容が点在している可能性もあります。
ここでできそうなのが、コンポーネント化です。
public class AutoActionController : MonoBehaviour
{
public bool IsAutoOn;
public void ActionExecute()
{
// 行動を自動設定する処理
}
}
仮でオート状態のスイッチをここに置いてますがこれは全体の管理クラスでも良いかもです。
やりたいのは、このコンポーネントがついていたら中で条件分岐が行われたり、特定の処理を行ったりをしてくれる仕組みの作成です。
追加するルールによっては、今までとはまったくステージを進める流れが違うためそこだけユニークな処理になってしまいそう、、といった問題が出てきそうですがコンポーネントを付け替えるだけで対応できるようになります。
Step5
スキル開発でも状態異常がついたり、特定の効果が発動したりする際はその専用のクラスやコンポーネントを付与したりする形の設計が一番やりやすそうですが、Step4までの結論ゲームルールの開発においてもなるべくコンポーネント指向でやった方が都合が良さそうなことがわかりました。
あとはどこまで突き詰めるかというところで、ManagerとかControllerとかなんでもやれちゃうような名前は適していなかったりするため例えば、
public class BattleRetryExecutor
{
public void Execute()
{
// リトライ処理
}
}
バトルのリトライを実行するクラスを作り、必要ならばルール別に用意してExecuteメソッドを叩くだけでリトライできるようにしておくというものです。
(一部リファクタリングの中でやったりしました)
リスクの高い箇所の実装こそ、こういった処理の分離や可読性の向上は大切になってくるので、今後も最適な設計を目指して試行錯誤していこうと思います。
最後に
今回の設計は一例です。もし他に適した設計案があればぜひ教えていただけたらと思います。
もし新規開発等で参考にできそうでしたら役立ててもらえたら嬉しいです。
明日は @shintsu_takamasa さんの記事です。