見出し画像

【#1インディーゲーム開発日誌】GOMANのリトライシステムについて

こんにちは
ブラストエッジゲームズでリードエンジニアをしているNARIといいます。
このコーナーでは株式会社ブラストエッジゲームズで現在絶賛開発中の
インディーゲームについて記事にしていきたいと思います。
今回は、GOMANについてやります。

※絶賛開発中のタイトルのため今後、記載されてる機能が変更される可能性がありますのでご了承ください。


GOMANとは

当社で開発している「GOMAN -stuck in the avici hell」という
3Dの横スクロールアクションゲームになります。
規模としましては10名程の少人数で開発中の
オリジナルタイトルのインディーゲームになります。
今回、GOMANの特徴的な要素「刹那戻し」システムについて解説します。


「刹那戻し」システムについて

GOMANでは、1度でも攻撃を受けるとプレイヤーは死亡します。
そのため敵が攻撃をしてくる場合は、回避やガードをする必要があります。
通常のゲームでは、死亡するとチェックポイントやステージの最初に戻る事が多いと思います。

しかし、GOMANでは、死亡しても攻撃される直前にゲームが巻き戻りやり直すことができます。
これが「刹那戻し」システムになります。
「刹那戻し」で巻き戻った後も敵は同じ攻撃を仕掛けてきます。
プレイヤーは、死亡復活のループを繰り返して敵の攻撃タイミングを見定め
パリィ、見切りを行いながら隙を突いてプレイヤーからも攻撃を行い
敵を倒していくゲームになります。

刹那戻し中は、巻き戻しの過程を行う演出が差し込まれます。
この「刹那戻し」の流れを繰り返してパリィや回避のタイミングを学習してクリアしてくゲームになっています。

また、GOMANでは「刹那戻し」以外にも2つのリトライがあります。
「刹那戻し」が3回以上行われると戦闘開始前まで巻き戻る「対峙戻し」が発生します。
更に敗北するとチェックポイントに巻き戻る「冥府戻し」が発生します。
今回は、このリトライシステムの中の「刹那戻し」について技術的な解説をします。


より技術的な補足

この「刹那戻し」を実現するためにどうしているか。
技術的な話をしたいと思います。
最初に、今回のリトライシステムは、自分が設計したものではありません。
しかし、非常によくできたシステムなので、ご紹介させていただきます。

状態を記録するための設計について

GOMANでは、リトライに必要な状態をキャッシュするためのクラスを作りそこに情報を記録していきます。
一般的なゲームのリプレイ機能のようなものです。

最近だと「ゼルダの伝説ティアーズオブキングダム」でも「モドレコ」という機能でもオブジェクトの動きを巻き戻していましたが見た目としては、似たような機能になります。

この情報の記録をGOMANでどのように行っているか説明します。
リトライが開始されるまでの流れは以下のようになっています。

上記の図を説明すると、巻き戻りシステムのプログラム内部では、リトライに必要なデータをRollbackという言葉で統一しています。

RollbackComponentは、敵やギミックや弾を定義するBPに設定されます。

RollbackComponent からRollbackModel というBPを設定します。

RollbackModel では、PlayerStateRollbackModelEenemyStateRollbackModel のような粒度で作られC++でリトライで記録したい変数を設定します。

LevelScriptActorにあるRetrySystem では毎フレームTickで記録が必要なRollbackModel のキャッシュを行っております。

また、チェックポイントを踏んだり敵を攻撃したりプレイヤー死亡したりした場合はRetrySystemに通知してフレーム時間をキャッシュしています。

実際にプレイヤーが死亡した際は、リトライの種類に応じて巻き戻すフレームを指定して復帰します。
例えば「刹那戻し」であれば、敵が攻撃をした直前のフレームの数を指定して巻き戻しの演出を行いながらリトライをします。

もう一つあるRetryProducer では、リトライに応じて必要な演出の出し分けや一時停止をしてから巻き戻しをするなどの流れを制御しています。
例えば「刹那戻し」では、攻撃を受けた瞬間にキャラクターのアニメーションを止めて赤い半透明のキャラクターを表示して巻き戻してから青い半透明のキャラクターを表示して攻撃をもう一度繰り返してから通常のゲームに復帰するという流れになっています。
こうした制御をRetryProducer で行っています。

「刹那戻し」は、単純なキャラクターの状態復帰だけでなくゲーム全体の状態(場合によっては敵の出現状態や戦闘の進行状態など)も復元する必要があるため、復帰が必要なデータはRollbackModel を作り上記のリトライシステムで統一して復帰ができるように作られています。

続いてこのRollbackModel がどういうものなのかを解説します

基本的にプレイヤーや敵、ギミックなどの機能の粒度でそれぞれ巻き戻し復帰に必要なパラメーターを定義してキャッシュしています。

このRollbackModel がどのように設計で呼び出されているか解説します。

メソッドの呼び出しなどは共通のInterface(RollbackModelInterface)を継承しておき敵単体のBPなどのRollbackModelを選択するComponent(RollbackComponent)を用意して
リトライシステムのデータを管理するクラス(RetrySystem)で必要なタイミングでRollbackModelInterfaceのメソッド実行する構造になっています。
この設計によって敵などのBPアクターを個別で作ると自動でリトライに必要なコンポーネントなどが作られてRetrySystemに登録されるようになっています。

ここまでで設計の話をしました。
次は、パラメーターがどういう形でキャッシュされているか解説します。


パラメーターの具体的なキャッシュ方法

各パラメーターの状態を記録するには、フレーム単位でキャッシュする必要があります。
こちらはFrameValueRecorderというTemplateクラスを用意してフレームを用意して管理を行っています。
最初にも少し説明しましたがGOMANでは攻撃の直前に戻るリトライ以外にもチェックポイントに戻るなどのリトライも存在します。

更に記録したデータを復帰する際は以下のように変数を作成しています。

リトライは大きく2種類分けており、毎フレームキャッシュするものはTArrayでデータをキャッシュし基本的には、「刹那戻し」で敵の攻撃前に戻る際に使用されます。

このデータは、キャッシュできる最大サイズを決めており「刹那戻し」で戻れる最大秒数を上限にして上限を超えたら上書きするようにしてデータが肥大化しないように扱っています。
それ以外のチェックポイントなどに復帰するデータは、TMapでリトライの種類とセットでキャッシュする変数を用意して大きく戻る場合はこのデータを参照しています。

この作りによりアクション直前に戻る小さいリトライとチェックポイントに戻る大きなリトライの2つを共通の仕組みで管理できるようにしています。

続いて、このフレーム単位の情報がどのようにキャッシュされたり復元されるたりするかを説明します。

パラメーターの流れを図にすると、RetrySystemの内部で、FrameTimerがTick内でフレームの計算を行い、キャラクターが行動したときやチェックポイントに到達した際は、OrderFrameに起点になるフレーム数をキャッシュしています。

更にRetrySystem のTickでは、毎フレームの情報ををRollbackModelを通じてキャッシュしています。
そしてプレイヤーが死亡した際は、OrderFrameを参照して各アクターが保持してるRollbackModelにアクセスして各オブジェクトを巻き戻すようにしています。

この仕組みによりリトライをする際はリトライ先のフレームを指定するだけで状態の復帰ができるようになっています。


最後に具体例としてFrameValueRecorder がどういった実装になっているか解説します。

template<class T>
class TPJ_IngameFrameValueRecorder
{
private:

	// 各フレームの値
	TArray<T> ValuesByFrame;
	// リトライの種別を保持
	TMap<FPJ_IngameRollbackLevel, T> ValuesByRollbackLevel;

private:

  // FPJ_IngameFrameTimerは現在のフレーム数や最大フレーム数などを管理している
	void Initialize(const FPJ_IngameFrameTimer& FrameTimer)
	{
	  //最大フレーム数で空配列を作成している
		ValuesByFrame.Empty(FrameTimer.GetRecordFrameCount());
		ValuesByFrame.SetNum(FrameTimer.GetRecordFrameCount());
	}

public:

	void RecordValue(const FPJ_IngameRollbackLevel& RollbackLevel, const T& Value, const FPJ_IngameFrameTimer& FrameTimer)
	{
	  // GOMAN専用の関数
	  // 刹那戻し以外のリトライかどうか確認
		if (PJ_IngameRetrySystemUtil::IsRespawnRollbackLevel(RollbackLevel))
		{
			ValuesByRollbackLevel.Add(RollbackLevel, Value);
		}
		else
		{
			if (ValuesByFrame.Num() == 0)
			{
				Initialize(FrameTimer);
			}

			ValuesByFrame[FrameTimer.GetTotalFrame() % FrameTimer.GetRecordFrameCount()] = Value;
		}
	}

	T GetValue(const FPJ_IngameRollbackLevel& RollbackLevel, int32 TargetFrame, const FPJ_IngameFrameTimer& FrameTimer)
	{
	  // GOMAN専用の関数
	  // 刹那戻し以外のリトライかどうか確認
		if (PJ_IngameRetrySystemUtil::IsRespawnRollbackLevel(RollbackLevel))
		{
		  // チェックポイント先のデータが存在してるか確認
			if(!ValuesByRollbackLevel.Contains(RollbackLevel))
			{
			  // 存在してない場合は刹那戻し用のデータから復元する
				return ValuesByFrame[TargetFrame % FrameTimer.GetRecordFrameCount()];
			}

			Initialize(FrameTimer);
			return ValuesByRollbackLevel[RollbackLevel];
		}
		else
		{
		  // 巻き戻る先のフレームが存在しているか確認
			if (!FrameTimer.IsValidFrame(TargetFrame))
			{
			  // アサート用のメッセージボックスを表示
			  // 元社員の方が制作したPluginを活用してます 感謝🙏
			  // <https://github.com/yKimisaki/DILoggerPlugin>
				DI_ASSERT(IngameFrameTimerLog, TEXT("IngameFrameValueRecorder::GetValueへのFrameの値が古すぎます。"));
			}

			if (ValuesByFrame.Num() == 0)
			{
				Initialize(FrameTimer);
			}

			return ValuesByFrame[TargetFrame % FrameTimer.GetRecordFrameCount()];
		}
	}
};

FPJ_IngameRollbackLevel はリトライの種類になります。

ValuesByFrameは「刹那戻し」で必要な対象のみ毎フレーム記録します。

それとは別でValuesByRollbackLevelに「対峙戻し」や「冥府戻し」といった戦闘開始前やチェックポイント通過時のタイミングでもフレームの状態を記録しています。

FPJ_IngameFrameTimerは現在のフレーム時間の管理とキャッシュする最大フレーム数の設定を行っています。

Initializeが呼ばれる際は、各フレームにキャッシュされた配列をリセットしつつ同時にキャッシュする最大フレーム数分、配列を確保し直しています。

if (PJ_IngameRetrySystemUtil::IsRespawnRollbackLevel(RollbackLevel))
上記コードは、リトライ時に指定のタイプであるかを確認しています。
この処理で「刹那戻し」かそれ以外のリトライかを判別してフレームに対してどのようにデータをキャッシュするかもしくは取得させるかを分岐させています。

「刹那戻し」で必要なデータは毎フレームキャッシュされるためそのまま全ての経過フレームを保存するとメモリを非常に圧迫します。そのため上限フレームを決めておき、上限を超えたら上書きする仕組みにしています。

データにアクセスする際はValuesByFrame[TargetFrame % FrameTimer.GetRecordFrameCount()] のような形で配列を扱いフレーム上限のデータで扱うようにしています。
この変数を使うことで変数のデータを扱う際にフレームの上限やリトライの種類に合わせたデータの出し分けなどを意識せずフレームの保存と取得をするだけで住むようになります。

例えば座標をキャッシュしたい場合は TPJ_IngameFrameValueRecorder<FTransform> ActorTransRecord;
と変数定義します。
そして、フレームを記録するタイミングで
ActorTransRecord.RecordValue(RollbackLevel,Actor->GetActorTransform(), FrameTimer);
と実装します。
さらに、フレームを復元するタイミングで
Actor->SetActorTransform(ActorTransRecord.GetValue(RollbackLevel, TargetFrame, FrameTimer));

と実装すればリトライの種別に応じたフレームのキャッシュや取得ができるようになっています。

この仕組みにより新しく機能を追加する場合でもこの変数を使うことで手軽にリトライを考慮した改修ができるようになっています。
また、RollbackModel には変数の状態だけでなく、ポーズもRollbackModel内で記録しています。
アニメーションの途中キャッシュなどは、FPoseSnapshotを使うと

FPoseSnapshot PoseSnapshot;
SkeletalMeshComponent->SnapshotPose(PoseSnapshot);
PoseSnapshotRecorder.RecordValue(RollbackLevel, PoseSnapshot, FrameTimer);

のような形でキャッシュでき上記のデータを使い復旧することができます。
スナップショットしたデータはAnimationBluePrintで合成するようにしています

まとめ

今回、GOMANで使用している刹那戻しのシステムについて解説しました。

この仕組みによって敵の攻撃を1アクション分のパラメーターを復元しつつ、アニメーションの動きも巻き戻せるようになっています。

GOMANでは、新しい攻撃タイプやギミックを持つ敵の実装時は、通常のゲーム開発と異なりこうした巻き戻しを考慮して実装する必要があります。

この仕組みにより、新規実装する際に以下の利点があります。

  • リトライの呼び出しはRetrySystemを経由して集約して管理できる

  • 復元をする場合はフレーム数とリトライの種類を送ることでリトライ先の復元が簡単にできる

  • FrameValueRecorderTemplate変数を使うことでフレーム上限を超えた際の考慮やチェックポイントの出し分けなどの制御を意識せずフレームのキャッシュや復元をすることができる

このシステムが出来上がってからプロジェクトに参加しましたが、新規実装をする際もリトライ周りは、フレームデータのキャッシュと読み込みをするだけで復元できてとても実装がしやすいシステムだなと感じました。
以上で今回GOMANの開発紹介を終わります。

GOMANは年内にアーリーアクセスを予定してます。
リリースされたら是非遊んで見てください!

また、株式会社ブラストエッジゲームズでは、一緒に開発してくれる仲間を絶賛募集中です!
会社へのエントリーもお待ちしてます!
https://www.blastedge-games.co.jp/recruit
一緒にUnreal Engineでゲームを作りましょう!
今回の知識が少しでも皆さんのゲーム開発に役立てばと思います。
では皆さん良いゲーム開発を!

この記事が参加している募集

仕事について話そう

この記事が気に入ったらサポートをしてみませんか?