[CH4-07] 위젯 버튼 애니메이션과 사운드, 레벨 이동 지연

김여울·2025년 9월 17일
2

내일배움캠프

목록 보기
78/111

1 위젯 버튼 애니메이션과 사운드

1.1 애니메이션


Hover = 1.0 → 1.1 : 마우스 대면 확대
Unhover = 1.1 → 1.0 : 마우스 빼면 원래 크기
Press = 1.0 → 0.9 : 클릭하면 줄어들기
Nickname_Committed 1.1 → 1.0 : 타자 칠 때마다 크기 변경

사운드는 Play Sound 2D로, 애니메이션은 Play Animation으로 연결하기

1.2 랜덤 사운드

닉네임 적으려고 타자칠 때 사운드 2개 랜덤 재생하고 싶음
.wav 로는 안되고 사운드큐로 변경해서 설정해야 한다
.wav 우클릭 후 Create Cue
사운드 2개 Random 재생하기
Play Sound 2D에 사운드큐 넣기

2 레벨 이동 지연

버튼 사운드 추가했는데 레벨 이동이 너무 빨라서 소리가 묻힌다...
그래서 레벨 이동을 지연시켜서 소리 나오게 수정했다!

2.1 TitlePlayerController

TitlePlayerController.h

#pragma once

#include "CoreMinimal.h"
#include "GameFramework/PlayerController.h"
#include "TitlePlayerController.generated.h"

/**
 * TitleLevel의 PlayerController
 * 김여울
 */
UCLASS()
class DC_API ATitlePlayerController : public APlayerController
{
	GENERATED_BODY()

public:
    ATitlePlayerController();

protected:
    virtual void BeginPlay() override;

public:
    // 레벨 지연 이동 - 버튼 소리가 안 들림
    UFUNCTION(BlueprintCallable)
    void RequestEnterWaitingLevelDeferred(const FString& InNickname, float DelaySeconds);

protected:
    // 서버에서 맵 이동 수행(전용 서버/리스닝 서버 대응)
    UFUNCTION(Server, Reliable)
    void ServerGoToWaitingLevel(const FString& InNickname);
    void ServerGoToWaitingLevel_Implementation(const FString& InNickname);

    // 테스트/싱글 실행 대비: 클라에서 로컬로 열어줌
    void ClientOpenWaitingLevelLocal();

    // UI 스폰
    void ShowTitleWidget();

private:
    // 실제 이동 로직 (즉시 실행용 내부 헬퍼)
    void DoEnterWaitingLevel(const FString& InNickname);

    // 타이머 만료시 실제 이동 실행
    UFUNCTION()
    void OnTravelTimerExpired();

private:
    // 위젯 블루프린트 할당용
    UPROPERTY(EditDefaultsOnly, Category = "UI")
    TSubclassOf<class UUserWidget> TitleWidgetClass;

    // 생성된 위젯 참조
    UPROPERTY()
    class UTitleLevelWidget* TitleWidgetRef;

    // 지연 이동용
    FTimerHandle TravelTimerHandle;
    FString PendingNickname;

};

TitlePlayerController.cpp

#include "../Title/TitlePlayerController.h"
#include "Blueprint/UserWidget.h"
#include "UI/TitleLevelWidget.h"
#include "Kismet/GameplayStatics.h"
#include "CineCameraActor.h"
#include "Game/DCGameInstance.h"

ATitlePlayerController::ATitlePlayerController()
{
    static ConstructorHelpers::FClassFinder<UUserWidget> WBP(TEXT("/Game/DC/UI/OutGame/WBP_TitleLevel.WBP_TitleLevel_C"));
    if (WBP.Succeeded())
    {
        TitleWidgetClass = WBP.Class;
    }
    bShowMouseCursor = true;
}

void ATitlePlayerController::BeginPlay()
{
    Super::BeginPlay();

    // 타이틀 UI 표시
    ShowTitleWidget();

    // 시네 카메라로 뷰 전환 (레벨에 1개 배치)
    TArray<AActor*> Cameras;
    UGameplayStatics::GetAllActorsOfClass(this, ACineCameraActor::StaticClass(), Cameras);
    if (Cameras.Num() > 0)
    {
        SetViewTargetWithBlend(Cameras[0], 0.f);
    }

    // 입력모드 : Game & UI
    FInputModeGameAndUI InputMode;
    InputMode.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock);
    SetInputMode(InputMode);
}

// 3초 지연 호출
void ATitlePlayerController::RequestEnterWaitingLevelDeferred(const FString& InNickname, float DelaySeconds)
{
    PendingNickname = InNickname;

    // 전용 서버 환경에서는 클라이언트에서 사운드 재생되도록 최소 0.25초 보장
    if (GetNetMode() == NM_Client && DelaySeconds < 0.25f)
    {
        DelaySeconds = 0.25f;
    }

    // 타이머 시작
    GetWorldTimerManager().ClearTimer(TravelTimerHandle);
    GetWorldTimerManager().SetTimer(
        TravelTimerHandle,
        this,
        &ATitlePlayerController::OnTravelTimerExpired,
        DelaySeconds,
        false
    );
}

void ATitlePlayerController::ShowTitleWidget()
{
    if (!TitleWidgetClass)
    {
        UE_LOG(LogTemp, Warning, TEXT("TitleWidgetClass 미지정: 에디터에서 ATitlePlayerController의 TitleWidgetClass 설정"));
        return;
    }

    if (!TitleWidgetRef)
    {
        UUserWidget* Created = CreateWidget<UUserWidget>(this, TitleWidgetClass);
        TitleWidgetRef = Cast<UTitleLevelWidget>(Created);
        if (TitleWidgetRef)
        {
            TitleWidgetRef->AddToViewport();
            UE_LOG(LogTemp, Log, TEXT("Title Widget Added To Viewport"));
        }
    }
}

void ATitlePlayerController::OnTravelTimerExpired()
{
    GetWorldTimerManager().ClearTimer(TravelTimerHandle);   // 타이머 정리
    DoEnterWaitingLevel(PendingNickname);   // 레벨 이동
}

void ATitlePlayerController::DoEnterWaitingLevel(const FString& InNickname)
{
    UE_LOG(LogTemp, Log, TEXT("▶ DoEnterWaitingLevel: %s"), *InNickname);

    // 닉네임 저장
    if (UDCGameInstance* GI = GetGameInstance<UDCGameInstance>())
    {
        GI->SetNickname(InNickname);
    }

    // 서버 권한이 있으면 서버가 직접 맵 이동
    if (HasAuthority())
    {
        ServerGoToWaitingLevel(InNickname);
        return;  // 여기서 끝냄
    }

    // 클라이언트면 서버에 요청
    ServerGoToWaitingLevel(InNickname);

    // 로컬 테스트(에디터 단독 실행)일 경우 대비
    if (!IsNetMode(NM_DedicatedServer))
    {
        ClientOpenWaitingLevelLocal();
    }
}

void ATitlePlayerController::ServerGoToWaitingLevel_Implementation(const FString& InNickname)
{
    UE_LOG(LogTemp, Warning, TEXT("▶ ServerGoToWaitingLevel_Implementation: %s"), *InNickname);
    // 서버에서만 실행되도록 보장
    if (!HasAuthority())
    {
        return; // 클라에서는 실행 안 함
    }

    UWorld* World = GetWorld();
    if (!World)
    {
        return;
    }

    // WaitingLevel로 맵 이동
    // Dedicated Server일 경우: 클라이언트는 이미 IP:Port로 접속해 있으므로
    // ServerTravel만 호출하면 접속 클라들이 자동으로 맵 이동
    World->ServerTravel(TEXT("/Game/DC/Maps/LV_Waiting.LV_Waiting"), true);
}

void ATitlePlayerController::ClientOpenWaitingLevelLocal()
{
    // 싱글/PIE 단독 실행시 편의용
    UGameplayStatics::OpenLevel(this, FName("LV_Waiting"));
}

2.2 TitleLevelWidget

TitleLevelWidget.cpp

#include "UI/TitleLevelWidget.h"
#include "Server/Title/TitlePlayerController.h"
#include "Kismet/KismetSystemLibrary.h"
#include "Kismet/GameplayStatics.h"
#include "InputCoreTypes.h" // EKeys

void UTitleLevelWidget::NativeConstruct()
{
    Super::NativeConstruct();

    // 버튼 클릭 바인딩
    if (StartButton)
    {
        StartButton->OnClicked.AddDynamic(this, &UTitleLevelWidget::OnStartClicked);
    }

    if (QuitButton)
    {
        QuitButton->OnClicked.AddDynamic(this, &UTitleLevelWidget::OnQuitClicked);
    }

    // Enter Key (텍스트 커밋) 바인딩
    if (EditableTextBox_Nickname)
    {
        // 입력 전에는 비워두고
        EditableTextBox_Nickname->SetText(FText::GetEmpty());
        EditableTextBox_Nickname->OnTextCommitted.AddDynamic(this, &UTitleLevelWidget::OnNameCommitted);
        EditableTextBox_Nickname->SetKeyboardFocus();
    }
}

FReply UTitleLevelWidget::NativeOnPreviewKeyDown(const FGeometry& InGeometry, const FKeyEvent& InKeyEvent)
{
    const FKey Key = InKeyEvent.GetKey();

    // 텍스트박스에 포커스가 있고, 엔터/확인 키면 → 닉네임만 캐시하고 전파 차단
    if (EditableTextBox_Nickname && EditableTextBox_Nickname->HasKeyboardFocus())
    {
        if (Key == EKeys::Enter || Key == EKeys::Virtual_Accept || Key == EKeys::Gamepad_FaceButton_Bottom)
        {
            CachedNickname = EditableTextBox_Nickname->GetText().ToString().TrimStartAndEnd();
            return FReply::Handled(); // 여기서 끝: 버튼으로 안 넘어감
        }
    }

    return Super::NativeOnPreviewKeyDown(InGeometry, InKeyEvent);
}

void UTitleLevelWidget::OnStartClicked()
{
    // 최신 닉네임 사용
    FString Nickname = EditableTextBox_Nickname
        ? EditableTextBox_Nickname->GetText().ToString()
        : CachedNickname;

    Nickname = Nickname.TrimStartAndEnd();
    if (Nickname.IsEmpty())
    {
        Nickname = TEXT("Player");
    }

    if (ATitlePlayerController* PC = GetTitlePC())
    {
        // 버튼 소리 나오게 레벨 지연 이동 (버튼 사운드 재생)
        PC->RequestEnterWaitingLevelDeferred(Nickname, 1.f);
    }
}

void UTitleLevelWidget::OnQuitClicked()
{
    // 1초 후 게임 종료 (사운드 재생 시간 확보용)
    FTimerHandle QuitTimerHandle;
    GetWorld()->GetTimerManager().SetTimer(
        QuitTimerHandle,
        this,
        &UTitleLevelWidget::QuitGameWithDelay,
        1.0f,
        false
    );
}

void UTitleLevelWidget::QuitGameWithDelay()
{
    // 게임 종료 처리
    if (APlayerController* PC = GetOwningPlayer())
    {
        UKismetSystemLibrary::QuitGame(this, PC, EQuitPreference::Quit, false);
    }
}

void UTitleLevelWidget::OnNameCommitted(const FText& Text, ETextCommit::Type CommitType)
{
    if (CommitType == ETextCommit::OnEnter)
    {
        // 닉네임만 저장, 이동 없음
        CachedNickname = Text.ToString().TrimStartAndEnd();
    }
}

class ATitlePlayerController* UTitleLevelWidget::GetTitlePC() const
{
    return GetOwningPlayer<ATitlePlayerController>();
}

2개의 댓글

comment-user-thumbnail
2025년 9월 17일

여울님의 글은 항상 부지런함이 넘쳐 고스란히 저에게도 전달된 그 땀을 느낄 수가 있어요.
상세한 설명은 물론이거니와 문제점을 찾고 해결하는데 있어 탁월한 해결을 보여주셨네요.

1개의 답글