Ch3 팀 프로젝트(7) - Action Prompt

yys·2026년 5월 20일

TIL

목록 보기
50/80

액션 프롬프트


Optional한 부분이지만, 게임의 가이드 용도로 아래와 같이 특정 기능이 어떠한 키와 바인딩 되었는지 보여주는 GUI가 존재하기도 한다.

오늘은 UI 가시화만 한다는 목표로, 디자인보다 구현에만 집중을 하였다.

클래스 설계


클래스의 구조를 그려보면 다음과 같다.

이전에 설계했던 아이템이 컨테이너에 할당되고 HUD에 보여지는 Debuff Bar와의 구조와 매우 유사하다.

컴파일 타임에 작업을 할까도 생각했지만, 플레이어가 키 바인딩을 바꾸게 되면 그에 맞춰서 "액션 - 키" 매핑도 바뀌어야 하기 때문에 컴파일 타임에는 이를 해결할 수 없다. 그래서 Debuff와 같이 런타임에서 항목을 추가하도록 세팅했다.

// PDActionPromptItemWidget.h
class PROJECTD_API UPDActionPromptItemWidget : public UUserWidget
{
	GENERATED_BODY()

public:
	UFUNCTION(BlueprintCallable, Category="ActionPrompt")
	void SetPrompt(UTexture2D* InIcon, const FText& InActionText);

protected:
	UPROPERTY(BlueprintReadOnly, meta=(BindWidget))
	TObjectPtr<UImage> Image_Key;

	UPROPERTY(BlueprintReadOnly, meta=(BindWidget))
	TObjectPtr<UTextBlock> Text_Action;
};

// PDActionPromptItemWidget.cpp
void UPDActionPromptItemWidget::SetPrompt(UTexture2D* InIcon, const FText& InActionText)
{
	if (Image_Key)
	{
		Image_Key->SetBrushFromTexture(InIcon);
		Image_Key->SetVisibility(InIcon ? ESlateVisibility::HitTestInvisible : ESlateVisibility::Hidden);
	}

	if (Text_Action)
	{
		Text_Action->SetText(InActionText);
	}
}

PDActionPromptItemWidget의 구조는 "액션 설명 - 설명에 부합하는 이미지"의 정보만 들어있는 단순한 구조이다. 텍스트와 이미지를 세팅해주는 것 외에는 특이한 점은 없다.

USTRUCT(BlueprintType)
struct FPDActionPromptEntry
{
	GENERATED_BODY()

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
	TObjectPtr<UInputAction> InputAction;

	// HUD에 표시될 액션 이름
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly)
	FText DisplayName;
};

// HUD 우하단에 노출할 상호작용 키 가이드 목록
UCLASS(BlueprintType)
class PROJECTD_API UPDActionPromptDataAsset : public UDataAsset
{
	GENERATED_BODY()

public:
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="ActionPrompt")
	TArray<FPDActionPromptEntry> Entries;
};

PDActionPromptItemWidget은 자신이 지니고 있는 액션 - 키 정보만 알고 있을 뿐, 어떠한 액션이 어떠한 키에 매핑되어야 하는지에 대한 정보는 알고 있지 않다.

Item에 TMap으로 이러한 매핑 정보를 담을 수 있겠지만, 아이템마다 매핑 정보를 담고있는 것은 비효율적이므로 매핑 정보를 지니는 데이터 에셋이라는 도서관을 만들고 Item에서 책만 꺼내는 방식으로 만들어주었다.

class PROJECTD_API UPDKeyIconDataAsset : public UPrimaryDataAsset
{
	GENERATED_BODY()

public:
	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="KeyIcon")
	TMap<FKey, TSoftObjectPtr<UTexture2D>> KeyIconMap;

	UPROPERTY(EditDefaultsOnly, BlueprintReadOnly, Category="KeyIcon")
	TSoftObjectPtr<UTexture2D> FallbackIcon;

	UFUNCTION(BlueprintCallable, Category="KeyIcon")
	UTexture2D* ResolveIcon(const FKey& Key) const;
};

또한 "상호작용 - E"처럼 텍스트로 키를 보여줄 수 있겠지만, 너무 밋밋하다고 판단하여 E에 대응하는 이미지를 매핑해주는 데이터 에셋도 만들어주었다.

// PDActionPromptListWidget.h
class PROJECTD_API UPDActionPromptListWidget : public UUserWidget
{
	GENERATED_BODY()

public:
	UFUNCTION(BlueprintCallable, Category="ActionPrompt")
	void RebuildPrompts();

protected:
	virtual void NativeConstruct() override;
	virtual void NativeDestruct() override;

	UPROPERTY(BlueprintReadOnly, meta=(BindWidget))
	TObjectPtr<UVerticalBox> Container_Prompts;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="ActionPrompt")
	TSubclassOf<UPDActionPromptItemWidget> ItemWidgetClass;
	
  	// 액션 설명 - 키 매핑이 들어있는 Data Asset
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="ActionPrompt")
	TSoftObjectPtr<UPDActionPromptDataAsset> PromptDataAsset;

	// IA → Key 매핑이 들어있는 IMC
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="ActionPrompt")
	TObjectPtr<UInputMappingContext> InputMappingContext;

	// Key → 키캡 이미지 매핑
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="ActionPrompt")
	TSoftObjectPtr<UPDKeyIconDataAsset> KeyIconMap;

private:
	UFUNCTION()
	void HandleControlMappingsRebuilt();

	FKey FindKeyForAction(const UInputAction* Action) const;
};

// PDActionPromptListWidget.cpp
void UPDActionPromptListWidget::NativeConstruct()
{
	Super::NativeConstruct();

	if (ULocalPlayer* LP = GetOwningLocalPlayer())
	{
		if (UEnhancedInputLocalPlayerSubsystem* Sub = LP->GetSubsystem<UEnhancedInputLocalPlayerSubsystem>())
		{
			Sub->ControlMappingsRebuiltDelegate.AddUniqueDynamic(this, &UPDActionPromptListWidget::HandleControlMappingsRebuilt);
		}
	}

	RebuildPrompts();
}

void UPDActionPromptListWidget::NativeDestruct()
{
	if (ULocalPlayer* LP = GetOwningLocalPlayer())
	{
		if (UEnhancedInputLocalPlayerSubsystem* Sub = LP->GetSubsystem<UEnhancedInputLocalPlayerSubsystem>())
		{
			Sub->ControlMappingsRebuiltDelegate.RemoveDynamic(this, &UPDActionPromptListWidget::HandleControlMappingsRebuilt);
		}
	}

	Super::NativeDestruct();
}

void UPDActionPromptListWidget::RebuildPrompts()
{
	if (!Container_Prompts)
	{
		return;
	}

	Container_Prompts->ClearChildren();

	UPDActionPromptDataAsset* DA = PromptDataAsset.LoadSynchronous();
	if (!DA || !ItemWidgetClass)
	{
		return;
	}

	UPDKeyIconDataAsset* IconMap = KeyIconMap.LoadSynchronous();

	for (const FPDActionPromptEntry& Entry : DA->Entries)
	{
		if (!Entry.InputAction)
		{
			continue;
		}

		const FKey Key = FindKeyForAction(Entry.InputAction);
		UTexture2D* Icon = (IconMap && Key.IsValid()) ? IconMap->ResolveIcon(Key) : nullptr;

		UPDActionPromptItemWidget* Item = CreateWidget<UPDActionPromptItemWidget>(GetOwningPlayer(), ItemWidgetClass);
		if (!Item)
		{
			continue;
		}

		Item->SetPrompt(Icon, Entry.DisplayName);
		Container_Prompts->AddChildToVerticalBox(Item);
	}
}

void UPDActionPromptListWidget::HandleControlMappingsRebuilt()
{
	RebuildPrompts();
}

FKey UPDActionPromptListWidget::FindKeyForAction(const UInputAction* Action) const
{
	if (!Action || !InputMappingContext)
	{
		return FKey();
	}

	for (const FEnhancedActionKeyMapping& Mapping : InputMappingContext->GetMappings())
	{
		if (Mapping.Action == Action)
		{
			return Mapping.Key;
		}
	}

	return FKey();
}

내용이 좀 길어서 요약하자면 다음과 같다.

  1. 위젯이 화면에 생성될 때, 로컬 플레이어의 입력 서브시스템을 찾아 입력 매핑 변경 이벤트에 델리게이트를 연결하고, 액션 프롬프트 UI를 최초로 생성(왜 로컬 플레이어의 서브시스템을 찾냐고 하면 이 게임은 멀티 용도로 구현하고 있기 때문)

  2. UI를 새로 그리기 위해 표시를 담당하는 컨테이너 내부의 자식 위젯들을 모두 삭제(따로 WBP에서 위젯을 추가하지 않는다면 작업을 안해도 되지만, 방어 코드로 작용)

  3. 액션 및 텍스트 정보가 담긴 데이터와 키별 아이콘 이미지 데이터를 가져옴

  4. 불러온 액션 목록을 순회하며, InputAction이 지정된 Input Mapping Context에서 어떤 키에 할당되어 있는지 발견

  5. 찾은 키에 맞는 아이콘 텍스처를 가져온 뒤, PDActionPromptItemWidget을 생성해 아이콘과 텍스트를 세팅하고 수직 박스 컨테이너에 차례대로 추가

  6. 게임 도중 유저가 키 설정을 바꾸어 매핑이 재구성되면, 1번에서 연결한 이벤트가 발동하여 2~5번 과정을 다시 실행해 UI를 즉시 새로고침

  7. 위젯이 화면에서 사라지거나 파괴될 때, 크래시 및 메모리 누수 방지를 위해 1번에서 연결했던 델리게이트 바인딩을 안전하게 해제

데이터 에셋 및 블루프린트 설계


먼저, PDActionPromptDataAsset을 가리키는 데이터 에셋을 생성하고 표현하고 싶은 내용들만 매핑을 해준다.
(Input Action - Input Mapping Context 간의 매핑은 이미 했다고 가정)

이후 키와 키 이미지를 매핑해주기 위해 따로 PDKeyIconDataAsset을 가리키는 데이터 에셋을 생성하고 매핑해준다.
참고로 에셋은 "Easy Input Prompts"를 사용하였다.

그 다음에는 PDActionPromptItemWidget의 WBP를 생성해주는데, 간단하게 BindWidget인 멤버만 추가해준다.

마지막으로 PDActionPromptListWidget의 WBP를 생성해주고, BindWidget인 멤버를 추가해준 후 각 프로퍼티들을 할당해준다.

그 다음 HUD에 적절한 위치에 배치해준 후, 플레이해보면 처음에 보았던 것처럼 나열이 되는 것을 볼 수 있다.

유의할 점이라면 PDActionPromptDataAsset에 할당된 순서대로 배치가 되기 때문에 할당 순서에 주의를 기울여야 한다.

오늘한 작업은 언급했듯이 Optional이기 때문에 폴리싱은 나중에 처리하고, 내일은 다른 중요한 부분을 처리할 계획이다.

profile
게임 개발 지망생

0개의 댓글