はじめに
もう12月も半ばを過ぎてあと少しで今年が終わることに戦慄しているエンジニアの坂本です
本日は、G-Blossomで開発中のタイトル 「ニンジャム!」 における実装した内容について一部書いていきます
僕自身はUnrealEngine経歴が浅く、仕事で利用したことはないため「あたりまえ」と感じる内容もあるかと思いますがご了承ください。
(宣伝)ニンジャム!とは!
ステージ(ラウンド)ごとにプレイヤーが競い合い、生き残ることで1ポイント獲得。
累計で特定ポイント達成したプレイヤーが勝利となるラウンド制パーティゲーム。
(エンジニアのUnrealEngine勉強のためにスタートしたタイトルでもあります)
エンジンは UnrealEngine5 を利用
僕は主にアウトゲームとステージギミックを担当しています。
(Steamのウィッシュリスト登録、Xのフォローは開発の励みになります!)
利用しているシステム、プラグインについて
ニンジャムでは以下を利用
- UnrealEngine 5.5.1
- IDE
- Rider
- キャラクタ制御
- GameplayAbilitySystem
- AI関連
- EQS
- オンライン実装関連
- EOS
- EOS Plus
- OnlineSubsystem
- UI関連
- CommonUI
-
Electronic Nodes
- ワイヤーが直線で表示され、見た目の不快感が無いのが良すぎる
-
GASCompanion
- GASの補助
-
TweenMaker
- UnityのDoTweenのようなActor制御を可能とさせるPlugin
-
UE5Coro
- UE上C++でコルーチンを簡単に利用可能になる。V2ではなくV1を利用しています
-
SimpleEventSubsystem
- UEのBP上で簡単にPubSubを利用したくて自分で作成したもの
利用したシステムの中で幾つか例を交えて紹介させていただきます
UE5Coro
主にバトル処理の中でシーケンス処理が必要な場合において、メソッドの中で待機処理を解決するために利用しています(co_await
で待機)
一連の手続きが1メソッドで完結するのでありがたい
UE5Coro::TCoroutine<> LoadNextStageLevelCore(TSoftObjectPtr<UWorld> NextLevel, FNinjaBattleLevelData LevelData)
{
// レベルがUnlaodされるまで待機
auto LevelManager = GetLevelManager();
co_await LevelManager->UnLoadLevelAsync(); // ← 待機処理
// 以前のステージのレベル破棄が完了した後の処理
LevelManager->OnAllLevelLoaded.Clear();
// クライアントはサーバーが終了してからではないと先に進めない
if (!HasAuthority())
{
while (!IsAuthorityLevelLoaded)
{
co_await Latent::Seconds(0.01f); // ←待機処理
}
}
....
}
SimpleEventSubsystem
特定の通知を受けたい時、interfaceを作成/継承せずとも、気軽に 1:N の通知を行えるように自分で作成しました
例として
カメラがキャラを収めようとする計算の中で必要なセーフエリア範囲を外部から変えるイベントの紹介です。
イベントクラスを以下のように定義
/**
* カメラのセーフエリアを変更する
*/
UCLASS(BlueprintType)
class NINJA_API UNinjaSetCameraSafeAreaEvent : public USimpleEventBase
{
GENERATED_BODY()
public:
// 設定するマージン値
UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = "Ninja|Camera")
FMargin SafeAreaMargin;
// 0より上であれば、この秒数後にリセットする
UPROPERTY(EditDefaultsOnly, BlueprintReadWrite, Category = "Ninja|Camera")
float ResetTime = -1.0f;
};
イベントを受け取りたいC++/BP上で、AddListener
を設定。
受け取るイベントクラスであるNinjaSetCamera
を指定
イベントを送りたいタイミングでイベントを作成し Trigger
によりイベントを送信
C++
// イベントを作成して
auto EventInstance = NewObject<UNinjaSetCameraSafeAreaEvent>();
EventInstance->SafeAreaMargin = FMargin(10.0f, 10.0f, 10.0f, 10.0f);
EventInstance->ResetTime = 4.0f;
// イベントをトリガー
USimpleEventManager::Get(WorldContextObject)->Trigger(EventInstance);
BP
以上でリスナー側がイベントを受け取れます。
「ゲームの開始通知」「キャラが倒れた通知」 など、インゲームで発生する通知をなるべくシンプルな操作で受け取ることを目的にしました。
※GASタグとの連携機能は入れたい。 そして他に便利なアセットはあると思います
CommonUI/Lyra
UE公式のプラグインであるCommonUIを使用しています。
僕が業務でUnrealEngineを利用したことがなかったことから、ニンジャムでは参考として公式サンプルであるLyraから実装を利用した箇所が多くあります。
CommonUI採用理由としてはUE公式サンプルプロジェクトであるLyraが利用しており実装が確認できること、そしてCommonUIの特徴である 「利用コンソールに応じたUI要素のサポート」 を利用したかったからです
CommonUI利用で改修した点
不具合と思われる挙動、そして自身のプロジェクトに合わず改修した箇所の紹介です
PIE起動終了後マウスカーソルが消える
終了時にSlateのSetCursorVisibilityを呼び出してあげることで解消。
以下の記事を参考にさせていただきました
https://qiita.com/Mitsunagi/items/0dfded740f3be414b514
マウスカーソルが中央でロックされる問題
入力モードを管理する
- SetInputMode_UIOnlyEx
- SetInputMode_GameOnly
- SetInputMode_GameAndUIEx
メソッドが存在しますが、CommonUI利用時にGameOnlyを利用すると必ずマウスカーソルが中央に移動する問題があります。
こちらの挙動については以下のGithub作者様が詳しく説明しているのでご確認ください
https://github.com/XistGG/LyraMouseTutorial
UCommonUIActionRouterBase::ApplyUIInputConfig
のコードを確認するとわかりますが bCenterCursor
フラグを利用してマウスカーソルを中央にロックしています
if (bCenterCursor)
{
TSharedPtr<FSlateUser> SlateUser = LocalPlayer.GetSlateUser();
TSharedPtr<IGameLayerManager> GameLayerManager = GameViewportClient->GetGameLayerManager();
if (ensure(SlateUser) && ensure(GameLayerManager))
{
FGeometry PlayerViewGeometry = GameLayerManager->GetPlayerWidgetHostGeometry(&LocalPlayer);
const FVector2D AbsoluteViewCenter = PlayerViewGeometry.GetAbsolutePositionAtCoordinates(FVector2D(0.5f, 0.5f));
SlateUser->SetCursorPosition(AbsoluteViewCenter);
UE_LOG(LogUIActionRouter, Verbose, TEXT("Moving the cursor to the viewport center."));
}
}
この挙動を修正するにはCommonActionRouterを継承しての改修と、SetInputMode を自分で作成する必要がありました
SetInputModeは以下の形で実装
void UNinjaUIFuncLibrary::SetUIInputMode(APlayerController* PlayerController, bool bMouseVisible, bool bIgnoreLookInput, bool bIgnoreMoveInput)
{
if (!IsValid(PlayerController))
{
return;
}
const ULocalPlayer* LocalPlayer = PlayerController->GetLocalPlayer();
if (!LocalPlayer)
{
return;
}
UCommonUIActionRouterBase* ActionRouter = LocalPlayer->GetSubsystem<UCommonUIActionRouterBase>();
if (!ActionRouter)
{
return;
}
FUIInputConfig NewInputConfig;
if (bMouseVisible)
{
// Input settings when mouse is Visible
constexpr bool bHideCursorDuringViewportCapture = false;
// NewInputConfig = FUIInputConfig(ECommonInputMode::Menu, EMouseCaptureMode::CaptureDuringMouseDown, bHideCursorDuringViewportCapture);
NewInputConfig = FUIInputConfig(ECommonInputMode::Menu, EMouseCaptureMode::NoCapture, bHideCursorDuringViewportCapture);
}
else
{
constexpr bool bHideCursorDuringViewportCapture = true;
// NewInputConfig = FUIInputConfig(ECommonInputMode::Game, EMouseCaptureMode::CapturePermanently_IncludingInitialMouseDown, bHideCursorDuringViewportCapture);
NewInputConfig = FUIInputConfig(ECommonInputMode::Game, EMouseCaptureMode::NoCapture, bHideCursorDuringViewportCapture);
}
NewInputConfig.bIgnoreLookInput = bIgnoreLookInput;
NewInputConfig.bIgnoreMoveInput = bIgnoreMoveInput; // 移動に必要なのでGameではtrueにすること
PlayerController->SetShowMouseCursor(bMouseVisible);
ActionRouter->SetActiveUIInputConfig(NewInputConfig);
}
そして、AcionRouterは UCommonUIActionRouterBase
を継承してActionRouter.ApplyUIInputConfigを実装します。
こちらは上記のGithubの内容をご確認ください
void UNinjaUIActionRouter::ApplyUIInputConfig(const FUIInputConfig& NewConfig, bool bForceRefresh)
{
// ここで必ずマウスカーソルが中央合わせにする対応があるのが注意点
//UCommonUIActionRouterBase::ApplyUIInputConfig(NewConfig, bForceRefresh);
...
}
全ゲームパッドでUI操作可能にする
※こちらは簡単な設定があるかもしれません
実装するゲームによりますが
ニンジャムではパーティゲームであり、ローカルプレイで遊ばれることを想定して1P以外のGamePadでもUIを操作可能にする必要がありました。
そのままでは1P操作フォーカスと2P以降の操作でのUIフォーカスのズレで、 複数のUIにフォーカスがあたってしまう という現象が発生しました。
キャラ選択時等はこの仕様で良いですが、タイトル、オプションでは フォーカス箇所は一箇所であるべきです。
1P以外は操作できないようにする実装が可能でしたが、ローカルプレイを考えるとどうにかしたかった為、FCommonAnalogCursur
を継承して改良することで解決しました。
FCommonAnalogCursur
クラスはパッド操作でUI選択可能にするサポートクラスです。
以下の形で継承して実装(まずコード全文のせます)
class FNinjaAnalogCursor : public FCommonAnalogCursor
{
public:
FNinjaAnalogCursor(const UCommonUIActionRouterBase& InActionRouter);
virtual bool HandleKeyDownEvent(FSlateApplication& SlateApp, const FKeyEvent& InKeyEvent) override;
virtual bool HandleKeyUpEvent(FSlateApplication& SlateApp, const FKeyEvent& InKeyEvent) override;
virtual bool HandleAnalogInputEvent(FSlateApplication& SlateApp, const FAnalogInputEvent& InAnalogInputEvent) override;
};
CPP
FNinjaAnalogCursor::FNinjaAnalogCursor(const UCommonUIActionRouterBase& InActionRouter)
: FCommonAnalogCursor(InActionRouter)
{
}
bool FNinjaAnalogCursor::HandleKeyDownEvent(FSlateApplication& SlateApp, const FKeyEvent& InKeyEvent)
{
// アナログ入力を1Pに渡してUI操作を統一させる
// 入力系がすべて1Pになると、PlayerによってKeyを処理したい時にすべて1Pと判断されてしまう。
// その場合を考慮して独自の変数を持たせることで、1P以外のPlayerの入力を処理することができる。
//
// この実装をすることで1Pにすべてコントロールが奪われてしまうため、ActiveInputConfigを見てUI操作中でない場合はそのまま流す
// ※ Allの場合は考慮してないので注意
//
// CommonUIのActiveInputConfigを設定すると、バトル中に決定ボタンが効かなくなる問題がある。回避として以下
// 1. CommonUIのInputModeではなく、PlayerControllerのInputModeで判断。CommonUIのActiveInputConfigを設定しない
// 2. FAnalogCursor::HandleKeyDownEventメソッド内でKeyDownをマウスイベントとして処理するコードがあることで、その先で何らかが消費している(Viewport周りっぽい)
// それを回避するためにGameモードのときはUIの消費はないとしてと早期return falseして後に処理を流すことで問題なくす
if (ActionRouter.GetActiveInputMode(ECommonInputMode::Game) == ECommonInputMode::Game)
{
return false; // UI操作では何も処理しないようにする
}
else if (InKeyEvent.GetUserIndex() == 0)
{
return FCommonAnalogCursor::HandleKeyDownEvent(SlateApp, InKeyEvent);
}
else
{
// 自分がUser0(Primary)以外であれば、Primaryに処理を流す。
// PrimaryUserにNinjaKeyEventの情報を保持しておく
const UWorld* World = ActionRouter.GetWorld();
if (World == nullptr) return false;
UNinjaGameInstance* GameInstance = UNinjaGameInstance::GetNinjaGameInstance(World);
auto NinjaKeyEvent = std::make_shared<FNinjaKeyEvent>(InKeyEvent);
GameInstance->SetReplaceKeyEventAtPrimary(NinjaKeyEvent);
SlateApp.ProcessKeyDownEvent(*NinjaKeyEvent);
// 解除
GameInstance->ResetReplaceKeyEventAtPrimary();
// 止めるのでtrue
return true;
}
}
bool FNinjaAnalogCursor::HandleKeyUpEvent(FSlateApplication& SlateApp, const FKeyEvent& InKeyEvent)
{
// Note: Downと同じ
if (ActionRouter.GetActiveInputMode(ECommonInputMode::Game) == ECommonInputMode::Game)
{
return false; // UI操作では何も処理しないようにする
}
else if (InKeyEvent.GetUserIndex() == 0)
{
return FCommonAnalogCursor::HandleKeyUpEvent(SlateApp, InKeyEvent);
}
else
{
const UWorld* World = ActionRouter.GetWorld();
if (World == nullptr) return false;
UNinjaGameInstance* GameInstance = UNinjaGameInstance::GetNinjaGameInstance(World);
auto NinjaKeyEvent = std::make_shared<FNinjaKeyEvent>(InKeyEvent);
GameInstance->SetReplaceKeyEventAtPrimary(NinjaKeyEvent);
SlateApp.ProcessKeyUpEvent(*NinjaKeyEvent);
GameInstance->ResetReplaceKeyEventAtPrimary();
return true;
}
}
bool FNinjaAnalogCursor::HandleAnalogInputEvent(FSlateApplication& SlateApp, const FAnalogInputEvent& InAnalogInputEvent)
{
// アナログ入力を1Pに渡してUI操作を統一させる
if (ActionRouter.GetActiveInputMode(ECommonInputMode::Game) == ECommonInputMode::Game)
{
return false; // UI操作では何も処理しないようにする
}
else if (InAnalogInputEvent.GetUserIndex() == 0)
{
return FCommonAnalogCursor::HandleAnalogInputEvent(SlateApp, InAnalogInputEvent);
}
else
{
// 自分がUser0(Primary)以外であれば、Primaryに処理を流す。
FAnalogInputEvent Tmp = FAnalogInputEvent(InAnalogInputEvent.GetKey(),
InAnalogInputEvent.GetModifierKeys(),
0, // Userは0でイベントを起こす
InAnalogInputEvent.IsRepeat(),
InAnalogInputEvent.GetCharacter(),
InAnalogInputEvent.GetKeyCode(),
InAnalogInputEvent.GetAnalogValue());
SlateApp.ProcessAnalogInputEvent(Tmp);
// 止めるのでtrue
return true;
}
}
KeyEventクラス
/**
* ActionRouterによって2P以降の操作をすべて1Pとしている。
*/
USTRUCT(BlueprintType)
struct NINJA_API FNinjaKeyEvent : public FKeyEvent
{
GENERATED_USTRUCT_BODY()
public:
FNinjaKeyEvent()
: FKeyEvent()
{
}
virtual ~FNinjaKeyEvent() = default;
FNinjaKeyEvent(const FKeyEvent InOriginKeyEvent)
: FKeyEvent(InOriginKeyEvent.GetKey(),
InOriginKeyEvent.GetModifierKeys(),
0 /* Userは0 でイベントを起こす*/,
InOriginKeyEvent.IsRepeat(),
InOriginKeyEvent.GetCharacter(),
InOriginKeyEvent.GetKeyCode()),
OriginKeyEvent(InOriginKeyEvent)
{
}
public:
uint32 GetOriginUserIndex() const { return OriginKeyEvent.GetUserIndex(); }
// このイベントが発生したときの元
FKeyEvent OriginKeyEvent;
};
やっていることは単純で以下
ゲーム操作時は何もしない(UI操作のみ影響あるようにする)
if (ActionRouter.GetActiveInputMode(ECommonInputMode::Game) == ECommonInputMode::Game)
{
return false; // UI操作では何も処理しないようにする
}
1Pの操作のときは元の処理をそのまま流す
if (InKeyEvent.GetUserIndex() == 0)
{
return FCommonAnalogCursor::HandleKeyDownEvent(SlateApp, InKeyEvent);
}
2P以降のときは1Pに操作を偽造する
// 自分がUser0(Primary)以外であれば、Primaryに処理を流す。
// PrimaryUserにNinjaKeyEventの情報を保持しておく
const UWorld* World = ActionRouter.GetWorld();
if (World == nullptr) return false;
UNinjaGameInstance* GameInstance = UNinjaGameInstance::GetNinjaGameInstance(World);
auto NinjaKeyEvent = std::make_shared<FNinjaKeyEvent>(InKeyEvent);
GameInstance->SetReplaceKeyEventAtPrimary(NinjaKeyEvent);
SlateApp.ProcessKeyDownEvent(*NinjaKeyEvent);
// 解除
GameInstance->ResetReplaceKeyEventAtPrimary();
// 止めるのでtrue
return true;
以上で2P以降の操作を1Pとして流すことでUIフォーカスが一箇所になるようにしました
※ SetReplaceKeyEventAtPrimary は元のKeyEventを保持しておいて、2P以降を判断したい箇所で利用しています
上記クラスを作成した場合、 FCommonAnalogCursor
継承クラスの MakeAnalogCursor
メソッドで返す必要があります
TSharedRef<FCommonAnalogCursor> UNinjaUIActionRouter::MakeAnalogCursor() const
{
// return FCommonAnalogCursor::CreateAnalogCursor(*this); // Original
return FCommonAnalogCursor::CreateAnalogCursor<FNinjaAnalogCursor>(*this);
}
最初に書いた通りこんなことをせずとも簡単な方法があるかもしれません...
なにか情報あれば共有よろしくお願いします
おわり
長くなったので一旦閉じます。見ていただきありがとうございました
LyraでCommonUI以外に参考した箇所で特に勉強になったのが
- レベルごとのコンテキストを保持する :
ExperienceDefinition
- オプション設定まわりのC++実装: (特に
PropertyPathHelpers::SetPropertyValueFromString
を利用し、設定値をすべてStringで保存することで形の違いを吸収する箇所。など) - GasTagとAbilityでバトル中のフェーズを管理する
PhaseSubsystem
Lyraプロジェクトは僕のUE先生ですね
また別日に続きの記事を書きます。
ニンジャム開発頑張っていきますので登録のほどよろしくお願いします!