260601 TIL - 미니게임 베이스를 만드는것과 고충들

2026. 6. 1. 21:16언리얼 7기 본캠프

지난 일주일간 TIL도 못쓰고 계속해서 미니게임 베이스 만드는것을 갈고닦으면서 겪었던 시행착오와 고충들에 대해서 재고해보려고 함

처음에는 베이스 클래스 하나 만들고 각 미니게임이 상속받아서 필요한 함수만 구현하면 끝날 줄 알았는데

근데 실제로 쓰다보니까 문제는 상속이 아니라 책임 분리였음

미니게임 베이스가 많이 알수록 편해지는 게 아니라, 오히려 각 미니게임의 예외를 전부 떠안는 구조가 됐음

그래서 이번 작업의 핵심은 베이스가 뭘 해야 하는지가 아니라, 베이스가 뭘 몰라야 하는지를 정하는 일임


처음 마주한 문제

처음에는 베이스가 거의 모든 흐름을 처리하게 만들려고 함

미니게임 시작
채보 진행
입력 처리
판정 처리
점수 계산
결과 생성
UI 호출
사운드 호출

이렇게 만들면 편할것 같았지만 이는 옳지 못한 구조였음

점프점프의 ActionA와 주사위 굴리기의 ActionA는 같은 A가 아님

점프점프에서는 캐릭터 착지 타이밍이고, 주사위 굴리기에서는 왼쪽 이동으로 처리해서, 각각 게임마다 가리키는 액션이 다름

베이스가 ActionA의 의미까지 알기 시작하면 결국 이런 코드가 들어가게 됨

if (MiniGameId == "JumpJump")
{
    // 왼쪽 캐릭터 착지
}
else if (MiniGameId == "Dice")
{
    // 왼쪽 이동
}

이런 분기가 들어가는 순간 베이스 클래스가 아닌, 그냥 모든 미니게임의 규칙을 알고 있는 거대한 매니저가 됨

그래서 베이스에서는 액션의 의미를 해석하지 않기로 결정함

베이스가 아는 것
- 특정 시간에 ActionA가 발생했다는 사실
- 입력 판정 결과가 나왔다는 사실
- 라운드가 시작됐고 끝났다는 사실

베이스가 모르는 것
- ActionA가 점프인지 이동인지 동작인지
- 성공 연출을 어떻게 보여줄지
- 실패 시 어떤 애니메이션을 재생할지

이 선을 긋고 나서 구조가 정리되기 시작했음


Context를 먼저 잡기

미니게임 하나를 실행하려면 생각보다 많은 정보가 필요했고, 현재 미니게임을 실행하는 데 필요한 값들은 Context로 묶어야 했음

USTRUCT(BlueprintType)
struct FPTBMiniGameContext
{
    GENERATED_BODY()

public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rhythm")
    FPTBGameSessionRequest SessionRequest;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rhythm")
    FPTBChartData ChartData;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rhythm")
    FPTBUserSettings UserSettings;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rhythm")
    int32 LocalPlayerIndex;
};

미니게임 베이스는 MiniGameId, Difficulty, ChartData, UserSettings를 따로따로 받기보단, 라운드 실행에 필요한 값을 Context 하나로 받고, 그 이후에는 Context 기준으로 동작해야 했음

void InitializeMiniGame(const FPTBMiniGameContext& InContext)
{
    Context = InContext;

    ChartData = Context.ChartData;
    UserSettings = Context.UserSettings;
}

이렇게 정리하니까 난이도나 채보만 바꾸고 싶을 때 Context만 바꿔서 전달하면 되니 편한 부분이 있었음


ChartData를 해석하지 않고 전달하기

FPTBChartData에는 채보 전체 정보가 들어갔음

USTRUCT(BlueprintType)
struct FPTBChartData
{
    GENERATED_BODY()

public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rhythm")
    FName ChartId;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rhythm")
    FName SongId;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rhythm")
    FName MiniGameId;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rhythm")
    EPTBDifficulty Difficulty;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rhythm")
    float BPM;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rhythm")
    float OffsetMs;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rhythm")
    float SongLengthMs;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rhythm")
    FName WwiseEventName;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rhythm")
    FName WwiseBankName;
};

여기서 베이스가 직접 써야 하는 값은 제한적임

MiniGameId
Difficulty
BPM
OffsetMs
SongLengthMs
WwiseEventName
WwiseBankName

반대로 ActionA가 어떤 행동인지는 ChartData만 보고 결정할 것이 아닌, 각 게임이 직접 게임에 따라서 판단하고 정해야 함

그래서 미니게임 베이스는 ChartData를 해석하지 않고 Conductor와 JudgementSystem에 채보를 넘기는 역할만 맡김

void StartMiniGame()
{
    RhythmConductor->StartConductor(Context.ChartData, CurrentPlayingId);
    JudgementSystem->Initialize(Context.ChartData, Context.UserSettings.JudgementOffsetMs);
}

이렇게 하니까 베이스가 채보의 세부 의미를 몰라도 됐음


NoteEvent에는 공통 정보만 담기

노트 하나의 정보는 FPTBNoteEvent로 정리함

USTRUCT(BlueprintType)
struct FPTBNoteEvent
{
    GENERATED_BODY()

public:
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rhythm")
    int32 NoteId;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rhythm")
    float BeatTime;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rhythm")
    float TimeMs;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rhythm")
    EPTBActionType ActionType;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rhythm")
    bool bIsLongNote;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rhythm")
    float DurationBeat;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rhythm")
    FName SectionName;

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Rhythm")
    TMap<FName, FString> Payload;
};

ActionType은 공통 액션임

UENUM(BlueprintType)
enum class EPTBActionType : uint8
{
    None,
    ActionA,
    ActionB,
    ActionC,
    ActionD,
    ActionE
};

베이스는 이 이상 알 필요 없고, 다른 부분은 각각 다른 헬퍼클래스들의 역할임

Payload는 미니게임 전용 확장 데이터로 사용함

NoteEvent.Payload.Add("Target", "LeftCharacter");
NoteEvent.Payload.Add("TileScale", "0.75");

중요한 건 베이스가 Payload를 직접 파싱하지 않는다는 점임

베이스는 Payload를 그대로 미니게임에 넘기고, 각 미니게임이 자기 규칙대로 읽어야 함

void HandleNoteEvent(const FPTBNoteEvent& NoteEvent)
{
    OnMiniGameNoteEvent(NoteEvent);
}

이 구조로 정리하면서 공통 데이터와 전용 데이터를 분리해야 한다는 걸 배웠음


Conductor와 베이스의 관계

UPTBRhythmConductorComponent는 채보 진행을 담당함

핵심 함수는 이렇게 작성해둠

void StartConductor(const FPTBChartData& Data, int32 PlayingId);
void PauseConductor();
void ResumeConductor();
void StopConductor();

float GetCurrentMusicTimeMs() const;
float GetCurrentBeat() const;

Conductor는 각종 채보 관련 이벤트들도 발행함

UPROPERTY(BlueprintAssignable, Category = "Rhythm|Note")
FOnNoteCue OnNoteCue;

UPROPERTY(BlueprintAssignable, Category = "Rhythm|Note")
FOnNoteEvent OnNoteEvent;

UPROPERTY(BlueprintAssignable, Category = "Rhythm|Time")
FOnBeatTick OnBeatTick;

UPROPERTY(BlueprintAssignable, Category = "Rhythm|Time")
FOnBarTick OnBarTick;

UPROPERTY(BlueprintAssignable, Category = "Rhythm")
FOnChartEnd OnChartEnd;

베이스는 이 이벤트들을 받아서 각 미니게임으로 넘기는 중간 계층 역할을 해줌

void BindConductor()
{
    RhythmConductor->OnNoteCue.AddDynamic(this, &ThisClass::HandleNoteCue);
    RhythmConductor->OnNoteEvent.AddDynamic(this, &ThisClass::HandleNoteEvent);
    RhythmConductor->OnChartEnd.AddDynamic(this, &ThisClass::HandleChartEnd);
}

여기서 OnNoteCue와 OnNoteEvent를 나눈 게 중요했음

Cue가 없으면 Event하나만 있어야 하는데, 이러면 리듬게임에서 '보고 쳐야하는' 부분을 구현할 수 없기 때문.

OnNoteCue
- 미리 보여주는 타이밍
- 점프 준비
- 발자국 표시 등등

OnNoteEvent
- 실제 판정 타이밍
- 입력 비교
- 타이밍에 따른 판정 처리(퍼펙트, 굿 등)

JudgementSystem은 판정 계산만 담당

판정은 UPTBJudgementSystem 쪽으로 분리함

판정 범위는 일단 다음처럼 잡혀 있음(임시값)

float HitWindowHighPerfectMs = 21.0;
float HitWindowPerfectMs = 50.0;
float HitWindowGoodMs = 70.0;
float HitWindowMissMs = 120.0;

처음에는 베이스가 입력까지 받아서 판정하면 편할 거라고 봤는데, 입력 방식이 미니게임마다 다르기 때문에 베이스가 입력을 직접 처리하면 분기 지옥이 됨

그래서 각 미니게임은 자기 입력을 ActionType으로 변환하고, JudgementSystem은 계산만 하도록 정리함

FPTBJudgementResult UPTBJudgementSystem::EvaluateInput(
    EPTBActionType Action,
    float InputTimeMs
);
void AJumpMiniGame::OnLeftInput()
{
    const float InputTimeMs = RhythmConductor->GetCurrentMusicTimeMs();

    FPTBJudgementResult Result =
        JudgementSystem->EvaluateInput(EPTBActionType::ActionA, InputTimeMs);

    HandleJudgementResult(Result);
}

베이스는 입력을 직접 몰라도 되고, 판정 결과를 받아 공통 처리만 하면 됨

void HandleJudgementResult(const FPTBJudgementResult& Result)
{
    UpdateCommonScore(Result);
    OnMiniGameJudgement(Result);
}

이렇게 나누니까 입력 처리, 판정 계산, 연출 처리가 서로 많이 엉키지 않음


Result 생성도 베이스의 일

미니게임이 끝나면 Result 화면으로 넘길 데이터가 필요함

공통 결과에는 점수, 판정 개수, 콤보, 정확도, 등급 등등의 정보가 들어감

USTRUCT(BlueprintType)
struct FPTBRoundResult
{
    GENERATED_BODY()

public:
    FGuid ProfileId;
    FName MiniGameId;
    EPTBDifficulty Difficulty;

    int32 Score;

    int32 HighPerfectCount;
    int32 PerfectCount;
    int32 GoodCount;
    int32 MissCount;
    int32 MaxCombo;

    float AccuracyRate;
    EPTBGradeType Grade;

    int32 StarCount;
    int32 EarnedMoney;

    bool IsNewHighScore;

    FPTBMiniGameResultPayload MiniGamePayload;
};

공통 결과는 베이스가 만들 수 있음

하지만 미니게임 전용 결과는 베이스가 알면 안 됨

그래서 Result도 공통 데이터와 전용 Payload로 나눴음

USTRUCT(BlueprintType)
struct FPTBMiniGameResultPayload
{
    GENERATED_BODY()

public:
    FName PayloadType;

    TMap<FName, float> FloatValues;
    TMap<FName, int32> IntValues;
    TMap<FName, FString> StringValues;
};

베이스는 공통 Result를 만들고, 각 미니게임은 Payload만 채우는 구조로 정리함

FPTBRoundResult BuildRoundResult(EPTBRoundEndReason EndReason)
{
    FPTBRoundResult Result;

    Result.MiniGameId = Context.ChartData.MiniGameId;
    Result.Difficulty = Context.ChartData.Difficulty;
    Result.Score = CurrentScore;
    Result.MaxCombo = MaxCombo;

    Result.HighPerfectCount = HighPerfectCount;
    Result.PerfectCount = PerfectCount;
    Result.GoodCount = GoodCount;
    Result.MissCount = MissCount;

    Result.AccuracyRate = CalculateAccuracy();
    Result.Grade = CalculateGrade();

    Result.MiniGamePayload = BuildMiniGamePayload();

    return Result;
}

이 구조로 정리하니까 Result 화면은 공통 데이터를 안정적으로 받고, 미니게임별 특수 결과도 같이 처리할 수 있게 됨


Blueprint와 C++ 경계

미니게임 베이스를 만들면서 C++에 둘 것과 Blueprint에 열어둘 것도 정리해야 했음

C++에 다 넣으면 미니게임 하나 수정할 때마다 컴파일이 필요함

반대로 Blueprint에 다 넣으면 공통 흐름이 흩어짐

그래서 C++에는 흐름을 두고, Blueprint에는 연출을 열어두는 방향으로 정리함

UFUNCTION(BlueprintImplementableEvent)
void BP_OnMiniGameReady();

UFUNCTION(BlueprintImplementableEvent)
void BP_OnNoteCue(const FPTBNoteEvent& NoteEvent);

UFUNCTION(BlueprintImplementableEvent)
void BP_OnNoteEvent(const FPTBNoteEvent& NoteEvent);

UFUNCTION(BlueprintImplementableEvent)
void BP_OnJudgementResult(const FPTBJudgementResult& Result);

UFUNCTION(BlueprintImplementableEvent)
FPTBMiniGameResultPayload BP_BuildMiniGamePayload();

C++에서 고정할 것

Context 저장
Conductor 시작 / 정지
JudgementSystem 연결
Delegate 바인딩 / 해제
공통 Result 생성
라운드 생명주기

Blueprint에 열어둘 것

미니게임별 캐릭터 연출
NoteCue 시각화
NoteEvent 처리 방식
성공 / 실패 애니메이션
미니게임 전용 Result Payload

이 경계를 잡는 게 베이스 설계에서 제일 중요했음

베이스가 모든 걸 C++로 가져가면 확장성이 죽고, 모든 걸 Blueprint에 맡기면 공통성이 죽음


정리

이번 주 작업을 통해 미니게임 베이스의 역할을 이렇게 정리함

1. 미니게임 베이스는 미니게임을 구현하는 클래스가 아니며
2. 미니게임이 공통 흐름을 따라 실행되게 만드는 클래스이다

베이스가 책임지는 건 생명주기와 연결임

Context를 받음
Conductor를 시작함
JudgementSystem을 연결함
NoteCue / NoteEvent를 전달함
판정 결과를 공통 처리함
Result를 생성함
Delegate를 정리함

베이스가 책임지지 않는 건 미니게임 고유 규칙임

ActionA가 무슨 의미인지
어떤 애니메이션을 재생할지
성공 연출을 어떻게 보여줄지
실패 조건을 어떻게 표현할지
미니게임 전용 결과를 어떻게 만들지

처음에는 베이스를 크게 만드는것이 맞다고 생각했지면, 결과적으로 베이스는 작게 만들고 흐름만 강하게 잡아야 했음

한줄 정리 : 미니게임 베이스는 기능을 많이 모아둔 부모 클래스가 아니라, 각 미니게임이 같은 규칙으로 시작하고 끝나게 만드는 실행 틀이다