0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

G-BlossomAdvent Calendar 2024

Day 20

「ニンジャム!」開発記録①

Last updated at Posted at 2024-12-19

はじめに

もう12月も半ばを過ぎてあと少しで今年が終わることに戦慄しているエンジニアの坂本です

本日は、G-Blossomで開発中のタイトル 「ニンジャム!」 における実装した内容について一部書いていきます

僕自身はUnrealEngine経歴が浅く、仕事で利用したことはないため「あたりまえ」と感じる内容もあるかと思いますがご了承ください。

(宣伝)ニンジャム!とは!

メインビジュアル_配信用.png

ステージ(ラウンド)ごとにプレイヤーが競い合い、生き残ることで1ポイント獲得。
累計で特定ポイント達成したプレイヤーが勝利となるラウンド制パーティゲーム。

(エンジニアのUnrealEngine勉強のためにスタートしたタイトルでもあります)


エンジンは UnrealEngine5 を利用
僕は主にアウトゲームとステージギミックを担当しています。

(Steamのウィッシュリスト登録、Xのフォローは開発の励みになります!:pray:)


利用しているシステム、プラグインについて

ニンジャムでは以下を利用

  • 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 を指定

test1.png

イベントを送りたいタイミングでイベントを作成し 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

test2.png

以上でリスナー側がイベントを受け取れます。

「ゲームの開始通知」「キャラが倒れた通知」 など、インゲームで発生する通知をなるべくシンプルな操作で受け取ることを目的にしました。

※GASタグとの連携機能は入れたい。 そして他に便利なアセットはあると思います

CommonUI/Lyra

UE公式のプラグインであるCommonUIを使用しています。

僕が業務でUnrealEngineを利用したことがなかったことから、ニンジャムでは参考として公式サンプルであるLyraから実装を利用した箇所が多くあります。

CommonUI採用理由としてはUE公式サンプルプロジェクトであるLyraが利用しており実装が確認できること、そしてCommonUIの特徴である 「利用コンソールに応じたUI要素のサポート」 を利用したかったからです

DocumentURL https://dev.epicgames.com/documentation/ja-jp/unreal-engine/common-ui-plugin-for-advanced-user-interfaces-in-unreal-engine

CommonUI利用で改修した点

不具合と思われる挙動、そして自身のプロジェクトに合わず改修した箇所の紹介です

PIE起動終了後マウスカーソルが消える

終了時にSlateのSetCursorVisibilityを呼び出してあげることで解消。
以下の記事を参考にさせていただきました:pray:
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);
}

最初に書いた通りこんなことをせずとも簡単な方法があるかもしれません...
なにか情報あれば共有よろしくお願いします:bow_tone2:


おわり

長くなったので一旦閉じます。見ていただきありがとうございました

LyraでCommonUI以外に参考した箇所で特に勉強になったのが

  • レベルごとのコンテキストを保持する : ExperienceDefinition
  • オプション設定まわりのC++実装: (特に PropertyPathHelpers::SetPropertyValueFromString を利用し、設定値をすべてStringで保存することで形の違いを吸収する箇所。など)
  • GasTagとAbilityでバトル中のフェーズを管理する PhaseSubsystem

Lyraプロジェクトは僕のUE先生ですね

また別日に続きの記事を書きます。
ニンジャム開発頑張っていきますので登録のほどよろしくお願いします!

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?