<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>주철민 TIL</title>
    <link>https://jcmtil.tistory.com/</link>
    <description>jcmtil 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Thu, 25 Jun 2026 22:12:58 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>주철민</managingEditor>
    <item>
      <title>260601 TIL - 미니게임 베이스를 만드는것과 고충들</title>
      <link>https://jcmtil.tistory.com/111</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;지난 일주일간 TIL도 못쓰고 계속해서 미니게임 베이스 만드는것을 갈고닦으면서 겪었던 시행착오와 고충들에 대해서 재고해보려고 함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 베이스 클래스 하나 만들고 각 미니게임이 상속받아서 필요한 함수만 구현하면 끝날 줄 알았는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 실제로 쓰다보니까 문제는 상속이 아니라 책임 분리였음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미니게임 베이스가 많이 알수록 편해지는 게 아니라, 오히려 각 미니게임의 예외를 전부 떠안는 구조가 됐음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이번 작업의 핵심은 베이스가 뭘 해야 하는지가 아니라, 베이스가 뭘 몰라야 하는지를 정하는 일임&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;처음 마주한 문제&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 베이스가 거의 모든 흐름을 처리하게 만들려고 함&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;미니게임 시작
채보 진행
입력 처리
판정 처리
점수 계산
결과 생성
UI 호출
사운드 호출&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 만들면 편할것 같았지만 이는 옳지 못한 구조였음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;점프점프의 ActionA와 주사위 굴리기의 ActionA는 같은 A가 아님&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;점프점프에서는 캐릭터 착지 타이밍이고, 주사위 굴리기에서는 왼쪽 이동으로 처리해서, 각각 게임마다 가리키는 액션이 다름&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;베이스가 ActionA의 의미까지 알기 시작하면 결국 이런 코드가 들어가게 됨&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;if (MiniGameId == &quot;JumpJump&quot;)
{
    // 왼쪽 캐릭터 착지
}
else if (MiniGameId == &quot;Dice&quot;)
{
    // 왼쪽 이동
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 분기가 들어가는 순간 베이스 클래스가 아닌, 그냥 모든 미니게임의 규칙을 알고 있는 거대한 매니저가 됨&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 베이스에서는 액션의 의미를 해석하지 않기로 결정함&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;베이스가 아는 것
- 특정 시간에 ActionA가 발생했다는 사실
- 입력 판정 결과가 나왔다는 사실
- 라운드가 시작됐고 끝났다는 사실

베이스가 모르는 것
- ActionA가 점프인지 이동인지 동작인지
- 성공 연출을 어떻게 보여줄지
- 실패 시 어떤 애니메이션을 재생할지&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 선을 긋고 나서 구조가 정리되기 시작했음&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Context를 먼저 잡기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미니게임 하나를 실행하려면 생각보다 많은 정보가 필요했고, 현재 미니게임을 실행하는 데 필요한 값들은 Context로 묶어야 했음&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;USTRUCT(BlueprintType)
struct FPTBMiniGameContext
{
    GENERATED_BODY()

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

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

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

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Rhythm&quot;)
    int32 LocalPlayerIndex;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미니게임 베이스는 MiniGameId, Difficulty, ChartData, UserSettings를 따로따로 받기보단, 라운드 실행에 필요한 값을 Context 하나로 받고, 그 이후에는 Context 기준으로 동작해야 했음&lt;/p&gt;
&lt;pre class=&quot;nginx&quot;&gt;&lt;code&gt;void InitializeMiniGame(const FPTBMiniGameContext&amp;amp; InContext)
{
    Context = InContext;

    ChartData = Context.ChartData;
    UserSettings = Context.UserSettings;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 정리하니까 난이도나 채보만 바꾸고 싶을 때 Context만 바꿔서 전달하면 되니 편한 부분이 있었음&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;ChartData를 해석하지 않고 전달하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FPTBChartData에는 채보 전체 정보가 들어갔음&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;USTRUCT(BlueprintType)
struct FPTBChartData
{
    GENERATED_BODY()

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

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

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

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

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

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

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

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

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Rhythm&quot;)
    FName WwiseBankName;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 베이스가 직접 써야 하는 값은 제한적임&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;MiniGameId
Difficulty
BPM
OffsetMs
SongLengthMs
WwiseEventName
WwiseBankName&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 ActionA가 어떤 행동인지는 ChartData만 보고 결정할 것이 아닌, 각 게임이 직접 게임에 따라서 판단하고 정해야 함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 미니게임 베이스는 ChartData를 해석하지 않고 Conductor와 JudgementSystem에 채보를 넘기는 역할만 맡김&lt;/p&gt;
&lt;pre class=&quot;coq&quot;&gt;&lt;code&gt;void StartMiniGame()
{
    RhythmConductor-&amp;gt;StartConductor(Context.ChartData, CurrentPlayingId);
    JudgementSystem-&amp;gt;Initialize(Context.ChartData, Context.UserSettings.JudgementOffsetMs);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하니까 베이스가 채보의 세부 의미를 몰라도 됐음&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;NoteEvent에는 공통 정보만 담기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;노트 하나의 정보는 FPTBNoteEvent로 정리함&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;USTRUCT(BlueprintType)
struct FPTBNoteEvent
{
    GENERATED_BODY()

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

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

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

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

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

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

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

    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = &quot;Rhythm&quot;)
    TMap&amp;lt;FName, FString&amp;gt; Payload;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ActionType은 공통 액션임&lt;/p&gt;
&lt;pre class=&quot;crystal&quot;&gt;&lt;code&gt;UENUM(BlueprintType)
enum class EPTBActionType : uint8
{
    None,
    ActionA,
    ActionB,
    ActionC,
    ActionD,
    ActionE
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;베이스는 이 이상 알 필요 없고, 다른 부분은 각각 다른 헬퍼클래스들의 역할임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Payload는 미니게임 전용 확장 데이터로 사용함&lt;/p&gt;
&lt;pre class=&quot;dockerfile&quot;&gt;&lt;code&gt;NoteEvent.Payload.Add(&quot;Target&quot;, &quot;LeftCharacter&quot;);
NoteEvent.Payload.Add(&quot;TileScale&quot;, &quot;0.75&quot;);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 건 베이스가 Payload를 직접 파싱하지 않는다는 점임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;베이스는 Payload를 그대로 미니게임에 넘기고, 각 미니게임이 자기 규칙대로 읽어야 함&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void HandleNoteEvent(const FPTBNoteEvent&amp;amp; NoteEvent)
{
    OnMiniGameNoteEvent(NoteEvent);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조로 정리하면서 공통 데이터와 전용 데이터를 분리해야 한다는 걸 배웠음&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Conductor와 베이스의 관계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UPTBRhythmConductorComponent는 채보 진행을 담당함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;핵심 함수는 이렇게 작성해둠&lt;/p&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;void StartConductor(const FPTBChartData&amp;amp; Data, int32 PlayingId);
void PauseConductor();
void ResumeConductor();
void StopConductor();

float GetCurrentMusicTimeMs() const;
float GetCurrentBeat() const;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Conductor는 각종 채보 관련 이벤트들도 발행함&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;UPROPERTY(BlueprintAssignable, Category = &quot;Rhythm|Note&quot;)
FOnNoteCue OnNoteCue;

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

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

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

UPROPERTY(BlueprintAssignable, Category = &quot;Rhythm&quot;)
FOnChartEnd OnChartEnd;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;베이스는 이 이벤트들을 받아서 각 미니게임으로 넘기는 중간 계층 역할을 해줌&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;void BindConductor()
{
    RhythmConductor-&amp;gt;OnNoteCue.AddDynamic(this, &amp;amp;ThisClass::HandleNoteCue);
    RhythmConductor-&amp;gt;OnNoteEvent.AddDynamic(this, &amp;amp;ThisClass::HandleNoteEvent);
    RhythmConductor-&amp;gt;OnChartEnd.AddDynamic(this, &amp;amp;ThisClass::HandleChartEnd);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 OnNoteCue와 OnNoteEvent를 나눈 게 중요했음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Cue가 없으면 Event하나만 있어야 하는데, 이러면 리듬게임에서 '보고 쳐야하는' 부분을 구현할 수 없기 때문.&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;OnNoteCue
- 미리 보여주는 타이밍
- 점프 준비
- 발자국 표시 등등

OnNoteEvent
- 실제 판정 타이밍
- 입력 비교
- 타이밍에 따른 판정 처리(퍼펙트, 굿 등)&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;JudgementSystem은 판정 계산만 담당&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;판정은 UPTBJudgementSystem 쪽으로 분리함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;판정 범위는 일단 다음처럼 잡혀 있음(임시값)&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;float HitWindowHighPerfectMs = 21.0;
float HitWindowPerfectMs = 50.0;
float HitWindowGoodMs = 70.0;
float HitWindowMissMs = 120.0;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 베이스가 입력까지 받아서 판정하면 편할 거라고 봤는데, 입력 방식이 미니게임마다 다르기 때문에 베이스가 입력을 직접 처리하면 분기 지옥이 됨&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 각 미니게임은 자기 입력을 ActionType으로 변환하고, JudgementSystem은 계산만 하도록 정리함&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;FPTBJudgementResult UPTBJudgementSystem::EvaluateInput(
    EPTBActionType Action,
    float InputTimeMs
);&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;rust&quot;&gt;&lt;code&gt;void AJumpMiniGame::OnLeftInput()
{
    const float InputTimeMs = RhythmConductor-&amp;gt;GetCurrentMusicTimeMs();

    FPTBJudgementResult Result =
        JudgementSystem-&amp;gt;EvaluateInput(EPTBActionType::ActionA, InputTimeMs);

    HandleJudgementResult(Result);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;베이스는 입력을 직접 몰라도 되고, 판정 결과를 받아 공통 처리만 하면 됨&lt;/p&gt;
&lt;pre class=&quot;isbl&quot;&gt;&lt;code&gt;void HandleJudgementResult(const FPTBJudgementResult&amp;amp; Result)
{
    UpdateCommonScore(Result);
    OnMiniGameJudgement(Result);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 나누니까 입력 처리, 판정 계산, 연출 처리가 서로 많이 엉키지 않음&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Result 생성도 베이스의 일&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미니게임이 끝나면 Result 화면으로 넘길 데이터가 필요함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공통 결과에는 점수, 판정 개수, 콤보, 정확도, 등급 등등의 정보가 들어감&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;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;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공통 결과는 베이스가 만들 수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 미니게임 전용 결과는 베이스가 알면 안 됨&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Result도 공통 데이터와 전용 Payload로 나눴음&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;USTRUCT(BlueprintType)
struct FPTBMiniGameResultPayload
{
    GENERATED_BODY()

public:
    FName PayloadType;

    TMap&amp;lt;FName, float&amp;gt; FloatValues;
    TMap&amp;lt;FName, int32&amp;gt; IntValues;
    TMap&amp;lt;FName, FString&amp;gt; StringValues;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;베이스는 공통 Result를 만들고, 각 미니게임은 Payload만 채우는 구조로 정리함&lt;/p&gt;
&lt;pre class=&quot;rust&quot;&gt;&lt;code&gt;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;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조로 정리하니까 Result 화면은 공통 데이터를 안정적으로 받고, 미니게임별 특수 결과도 같이 처리할 수 있게 됨&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Blueprint와 C++ 경계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미니게임 베이스를 만들면서 C++에 둘 것과 Blueprint에 열어둘 것도 정리해야 했음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;C++에 다 넣으면 미니게임 하나 수정할 때마다 컴파일이 필요함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반대로 Blueprint에 다 넣으면 공통 흐름이 흩어짐&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 C++에는 흐름을 두고, Blueprint에는 연출을 열어두는 방향으로 정리함&lt;/p&gt;
&lt;pre class=&quot;lisp&quot;&gt;&lt;code&gt;UFUNCTION(BlueprintImplementableEvent)
void BP_OnMiniGameReady();

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

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

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

UFUNCTION(BlueprintImplementableEvent)
FPTBMiniGameResultPayload BP_BuildMiniGamePayload();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;C++에서 고정할 것&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;Context 저장
Conductor 시작 / 정지
JudgementSystem 연결
Delegate 바인딩 / 해제
공통 Result 생성
라운드 생명주기&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Blueprint에 열어둘 것&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;미니게임별 캐릭터 연출
NoteCue 시각화
NoteEvent 처리 방식
성공 / 실패 애니메이션
미니게임 전용 Result Payload&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 경계를 잡는 게 베이스 설계에서 제일 중요했음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;베이스가 모든 걸 C++로 가져가면 확장성이 죽고, 모든 걸 Blueprint에 맡기면 공통성이 죽음&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 주 작업을 통해 미니게임 베이스의 역할을 이렇게 정리함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 미니게임 베이스는 미니게임을 구현하는 클래스가 아니며&lt;br /&gt;2. 미니게임이 공통 흐름을 따라 실행되게 만드는 클래스이다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;베이스가 책임지는 건 생명주기와 연결임&lt;/p&gt;
&lt;pre class=&quot;oxygene&quot;&gt;&lt;code&gt;Context를 받음
Conductor를 시작함
JudgementSystem을 연결함
NoteCue / NoteEvent를 전달함
판정 결과를 공통 처리함
Result를 생성함
Delegate를 정리함&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;베이스가 책임지지 않는 건 미니게임 고유 규칙임&lt;/p&gt;
&lt;pre class=&quot;&quot;&gt;&lt;code&gt;ActionA가 무슨 의미인지
어떤 애니메이션을 재생할지
성공 연출을 어떻게 보여줄지
실패 조건을 어떻게 표현할지
미니게임 전용 결과를 어떻게 만들지&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 베이스를 크게 만드는것이 맞다고 생각했지면, 결과적으로 베이스는 작게 만들고 흐름만 강하게 잡아야 했음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한줄 정리 : 미니게임 베이스는 기능을 많이 모아둔 부모 클래스가 아니라, 각 미니게임이 같은 규칙으로 시작하고 끝나게 만드는 실행 틀이다&lt;/p&gt;</description>
      <category>언리얼 7기 본캠프</category>
      <author>주철민</author>
      <guid isPermaLink="true">https://jcmtil.tistory.com/111</guid>
      <comments>https://jcmtil.tistory.com/111#entry111comment</comments>
      <pubDate>Mon, 1 Jun 2026 21:16:35 +0900</pubDate>
    </item>
    <item>
      <title>260520 TIL - 채보와 wwise 시스템의 연동</title>
      <link>https://jcmtil.tistory.com/110</link>
      <description>&lt;h1&gt;&amp;nbsp;&lt;/h1&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 최종 팀 프로젝트에서 채보 편집기와 Wwise를 어떻게 연결할지 정리해봤음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에 Wwise를 프로젝트에 붙이고 관련 파일들을 정리하면서 느낀 건, Wwise 연동은 그냥 플러그인 설치하고 Event를 호출하는 문제가 아니었다는 점임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 우리 프로젝트는 일반적인 리듬게임 하나를 만드는 게 아니라, 여러 미니게임이 같은 채보 포맷을 공유하는 구조라서 채보와 사운드를 어떻게 분리할지가 중요했음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채보는 언제 어떤 액션이 발생하는지 알려주는 데이터고, Wwise는 그 액션과 판정 결과가 어떤 소리로 들릴지를 담당하는 오디오 레이어라고 보는 게 맞았음&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;오늘 작업한 내용&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘 작업하면서 크게 세 가지를 정리했음&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. Wwise 플러그인 파일을 팀 프로젝트에서 정상적으로 관리할 수 있게 설정
2. 채보 편집기에서 저장하는 JSON 메타데이터 구조 정리
3. 채보 데이터와 Wwise Event를 Unreal에서 어떻게 연결할지 구상&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Wwise를 붙이면서 생각보다 Git 설정에서 신경쓸 게 많았음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unreal 프로젝트 기본 gitignore 규칙에는 빌드 결과물이나 바이너리 파일을 무시하는 규칙이 많은데, Wwise 플러그인은 ThirdParty 쪽에 dll, lib, pdb 같은 SDK 파일들이 같이 들어감&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 이 파일들이 빠지면 내 PC에서는 열리는데 팀원 PC에서는 빌드가 깨질 수 있다는 점이었음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Wwise ThirdParty 파일이 ignore되지 않도록 예외를 열어두고, 바이너리 payload는 LFS로 관리하는 방향으로 정리했음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 작업을 하면서 느낀 건 Wwise 연동은 기능 구현 이전에 팀원들이 같은 환경을 받을 수 있게 만드는 게 먼저라는 점이었음&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;채보 편집기에서 저장해야 하는 정보&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 채보 편집기는 단순히 노트를 찍는 툴이 아니라, Unreal과 Wwise가 같이 쓸 메타데이터까지 저장하는 방향으로 잡았음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메타데이터에는 이런 값들이 들어감&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;chartVersion
difficulty
chartId
miniGameId
songId
bpm
offsetMs
wwise.bankName
wwise.bgmEventName&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미니게임 선택 화면에서 특정 채보를 골랐을 때, Unreal은 이 채보가 어떤 미니게임인지, 어떤 곡인지, BPM이 몇인지, offset이 얼마인지, 어떤 Wwise BGM Event를 틀어야 하는지 알아야 함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 채보 파일은 단순 노트 리스트가 아니라 라운드 시작에 필요한 기본 정보를 들고 있는 데이터가 되어야 했음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저장 구조는 이런 느낌으로 진행됨&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;chartVersion&quot;: 1,
  &quot;chartId&quot;: &quot;JumpJump_Standard_001&quot;,
  &quot;miniGameId&quot;: &quot;JumpJump&quot;,
  &quot;songId&quot;: &quot;Song_JumpJump_001&quot;,
  &quot;difficulty&quot;: &quot;Standard&quot;,
  &quot;bpm&quot;: 120,
  &quot;offsetMs&quot;: 0,
  &quot;wwise&quot;: {
    &quot;bankName&quot;: &quot;Bank_JumpJump&quot;,
    &quot;bgmEventName&quot;: &quot;Play_BGM_JumpJump&quot;
  },
  &quot;noteFormat&quot;: [&quot;id&quot;, &quot;timeMs&quot;, &quot;beat&quot;, &quot;action&quot;, &quot;lane&quot;, &quot;type&quot;],
  &quot;notes&quot;: [
    [1, 1000, 2, &quot;A&quot;, 0, &quot;Tap&quot;]
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건 &lt;code&gt;wwise.bankName&lt;/code&gt;과 &lt;code&gt;wwise.bgmEventName&lt;/code&gt;임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채보가 직접 Wwise Event Asset을 들고 있는 건 아니지만, 어떤 Bank와 어떤 BGM Event를 사용해야 하는지는 알려줘야 함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 채보는 Wwise를 직접 실행하지 않고, Wwise를 찾기 위한 이름만 들고 있는 구조임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해야 JSON 파일은 가볍게 유지하고, 실제 Unreal 에셋 참조는 Unreal 쪽에서 관리할 수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채보 편집기에서 noteFormat은 이 형태로 고정했음&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;[&quot;id&quot;, &quot;timeMs&quot;, &quot;beat&quot;, &quot;action&quot;, &quot;lane&quot;, &quot;type&quot;]&lt;/code&gt;&lt;/pre&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;beat
- 에디터에서 박자 기준으로 노트를 배치하기 좋음
- 1/4, 1/8, 1/16, 1/32 snap과 잘 맞음
- 마디와 박자를 디버깅하기 좋음

timeMs
- Unreal에서 입력 판정 계산에 바로 쓰기 좋음
- offsetMs 적용 결과를 확인하기 좋음
- 실제 플레이 시간과 비교하기 좋음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;A / B / C / D / E 액션과 미니게임의 관계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 프로젝트의 핵심은 모든 미니게임이 같은 채보 포맷을 공유한다는 점임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 채보에는 구체적인 행동 이름을 박아두는 게 아니라 A / B / C / D / E 같은 공통 액션만 저장함&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;A
B
C
D
E&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 각 미니게임이 이 액션을 자기 방식으로 해석함(아래는 예제로서 최종본이 아님)&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;점프점프
A : 왼쪽 캐릭터 착지
B : 가운데 캐릭터 착지
C : 오른쪽 캐릭터 착지

뒤뚱뒤뚱
A : 왼발
B : 오른발
C : 왼발
D : 오른발

주사위 굴리기
A : 왼쪽
B : 위
C : 오른쪽
D : 아래

따라쟁이 동물
A : 동작 1
B : 동작 2
C : 동작 3
D : 동작 4&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서 채보가 &lt;code&gt;Play_Jump_Land&lt;/code&gt; 같은 Wwise Event를 직접 들고 있으면 문제가 생김&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 A 액션이라도 미니게임마다 의미가 달라지기 때문임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 채보는 액션 타이밍만 관리하고, 실제 사운드는 미니게임과 AudioMap 쪽에서 결정해야 함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하면 이렇게 됨&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;채보
- 몇 ms에 A 액션이 발생하는지만 저장

미니게임
- A 액션을 자기 규칙에 맞게 해석

판정 시스템
- 입력 시간과 액션 타이밍을 비교

Wwise
- 판정 결과와 미니게임 상태에 맞는 사운드 재생&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해야 채보 포맷을 모든 미니게임에서 공유할 수 있음&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Unreal 구조체와 연결하기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 프로젝트 구조에서는 &lt;code&gt;FPTBChartData&lt;/code&gt;와 &lt;code&gt;FPTBNoteEvent&lt;/code&gt;가 이 역할을 맡게 될 것 같음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;FPTBChartData&lt;/code&gt;는 채보 전체 정보를 들고 있음&lt;/p&gt;
&lt;pre class=&quot;abnf&quot;&gt;&lt;code&gt;FName ChartId;
FName SongId;
FName MiniGameId;
EPTBDifficulty Difficulty;
float BPM;
float OffsetMs;
float SongLengthMs;
FName WwiseEventName;
FName WwiseBankName;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조를 보면 채보 메타데이터와 거의 1대1로 연결됨&lt;/p&gt;
&lt;pre class=&quot;xl&quot;&gt;&lt;code&gt;chartId        -&amp;gt; ChartId
songId         -&amp;gt; SongId
miniGameId     -&amp;gt; MiniGameId
difficulty     -&amp;gt; Difficulty
bpm            -&amp;gt; BPM
offsetMs       -&amp;gt; OffsetMs
wwise.bankName -&amp;gt; WwiseBankName
wwise.bgmEventName -&amp;gt; WwiseEventName&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;FPTBNoteEvent&lt;/code&gt;는 노트 하나의 정보를 들고 있음&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;int32 NoteId;
float BeatTime;
float TimeMs;
EPTBActionType ActionType;
bool bIsLongNote;
float DurationBeat;
FName SectionName;
TMap&amp;lt;FName, FString&amp;gt; Payload;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 &lt;code&gt;ActionType&lt;/code&gt;은 A / B / C / D / E를 Unreal enum으로 변환한 값이 됨&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채보 편집기에서 저장한 A는 &lt;code&gt;ActionA&lt;/code&gt;, B는 &lt;code&gt;ActionB&lt;/code&gt;로 바뀌어야 함&lt;/p&gt;
&lt;pre class=&quot;clean&quot;&gt;&lt;code&gt;A -&amp;gt; ActionA
B -&amp;gt; ActionB
C -&amp;gt; ActionC
D -&amp;gt; ActionD
E -&amp;gt; ActionE&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 매핑은 JSON 파서에서 확실하게 처리해야 할 것 같음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 이 부분이 틀어지면 편집기에서는 A를 찍었는데 게임에서는 B 액션이 나가는 식의 문제가 생길 수 있음&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Wwise EventMap을 따로 두는 이유&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채보에 Wwise Event 이름을 넣긴 하지만, 실제 &lt;code&gt;UAkAudioEvent&lt;/code&gt;를 채보가 직접 들고 있으면 안 된다고 봤음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JSON은 외부 파일이고, Unreal의 에셋 참조는 Unreal 안에서 관리하는 게 맞음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 &lt;code&gt;UPTBWwiseEventMapAsset&lt;/code&gt; 같은 데이터 에셋이 필요함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역할은 이렇게 나눌 수 있음&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;BGMEvents
- 미니게임별 BGM Event

JudgeEventMap
- HighPerfect / Perfect / Good / Miss 판정 사운드

UIEvents
- 선택 / 확인 / 취소 / 결과창 같은 UI 사운드

MiniGameSFXMap
- 점프 착지음, 주사위 굴림음, 낚시 릴 감는 소리 같은 미니게임 전용 사운드&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 두면 채보 파일은 이름만 알고 있고, 실제 Wwise Event Asset은 EventMap에서 찾아서 재생할 수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름은 이런 식으로 생각함&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;.rhythmc 로드
FPTBChartData 생성
WwiseBankName / WwiseEventName 확인
EventMapAsset에서 BGM Event 검색
BGM 재생
PlayingId를 Conductor에 전달
Conductor가 채보 진행
판정 결과에 따라 JudgeEventMap에서 SFX 재생&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조가 마음에 들었던 이유는 채보와 사운드 에셋의 결합을 줄일 수 있기 때문임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채보를 만든 사람이 Wwise 에셋 경로까지 알 필요는 없음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신 정해진 이름 규칙만 맞추면 됨&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Conductor가 맡아야 하는 역할&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채보와 Wwise 사이에서 가장 중요한 클래스는 Conductor가 될 것임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Conductor는 단순히 노트를 하나씩 꺼내는 클래스가 아니라, 현재 음악 시간과 채보 이벤트를 맞춰서 게임 전체에 알려주는 역할임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필요한 기능은 이 정도라고 봄&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;채보 데이터 저장
BGM PlayingId 저장
현재 음악 시간 계산
현재 Beat 계산
LookAheadBeats 기준으로 미리보기 큐 발행
판정 타이밍에 NoteEvent 발행
곡 종료 시 OnChartEnd 호출&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 LookAheadBeats가 중요함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 미니게임들은 노트가 판정선에 닿는 순간만 중요한 게 아니라, 그 전에 미리 보여줘야 하는 연출이 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들면 점프점프는 캐릭터가 먼저 점프하고 착지 타이밍을 맞춰야 하고, 뒤뚱뒤뚱은 발자국이나 걷는 모션이 먼저 보여야 하는 등의 연출이 필요함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Conductor는 두 종류의 이벤트를 던지는 게 좋아보임&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;OnNoteCue
- 미리 보여줄 연출용 이벤트

OnNoteEvent
- 실제 판정 타이밍 이벤트&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 나누면 미니게임 쪽에서 연출과 판정을 분리해서 처리할 수 있음&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;판정 시스템과 Wwise의 관계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 판정 기준은 이렇게 잡혀 있음&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;HighPerfect : 21ms
Perfect     : 50ms
Good        : 70ms
Miss        : 120ms&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;흐름은 이렇게 가야 함&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;플레이어 입력
JudgementSystem이 입력 시간과 노트 시간 비교
FPTBJudgementResult 생성
판정 타입에 따라 JudgeEventMap에서 Wwise Event 검색
Perfect / Good / Miss 사운드 재생
미니게임별 성공 / 실패 연출 실행&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 판정 로직과 오디오 연출이 분리됨&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;판정 윈도우를 조정해도 Wwise Event 구조를 바꿀 필요가 없고, 사운드가 바뀌어도 판정 로직이 흔들리지 않음&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;지금 기준으로 잡은 흐름&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 기준으로는 전체 흐름을 이렇게 잡고 있음&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;1. 채보 편집기에서 음악을 열고 BPM / Offset을 맞춤
2. A / B / C / D / E 액션을 beat 기준으로 찍음
3. chartId, miniGameId, songId, wwise.bankName, wwise.bgmEventName을 입력함
4. .rhythmc JSON으로 저장함
5. Unreal에서 JSON을 읽어 FPTBChartData와 FPTBNoteEvent 배열로 변환함
6. FPTBChartData의 WwiseEventName으로 BGM Event를 찾음
7. Wwise BGM을 재생하고 PlayingId를 Conductor에 넘김
8. Conductor가 현재 시간 기준으로 OnNoteCue와 OnNoteEvent를 발행함
9. JudgementSystem이 입력을 판정함
10. 판정 결과에 따라 Wwise Judge SFX를 재생함&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>언리얼 7기 본캠프</category>
      <author>주철민</author>
      <guid isPermaLink="true">https://jcmtil.tistory.com/110</guid>
      <comments>https://jcmtil.tistory.com/110#entry110comment</comments>
      <pubDate>Wed, 20 May 2026 21:34:33 +0900</pubDate>
    </item>
    <item>
      <title>260515 TIL - UE 5.6과 리듬게임 프로젝트, 그리고 Wwise</title>
      <link>https://jcmtil.tistory.com/109</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종 팀 프로젝트 장르가 리듬게임으로 결정되었고, UE에서 리듬게임을 만드는 방향성과 방법들에 대해서 스터디한 내용을 정리함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 팀 프로젝트는 단일 리듬게임이 아니라 여러 개의 리듬 미니게임을 하나의 흐름으로 묶는 캐주얼 리듬 미니게임 컬렉션임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기획 단계에서 정한 핵심은 미니게임마다 플레이 방식은 다르지만, 채보 데이터는 A / B / C / D 액션 이벤트 기반으로 통일한다는 점이었음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Wwise 적용도 단순히 BGM을 재생하고 효과음을 붙이는 문제가 아니라, 팀에서 정한 통합 채보 포맷과 미니게임별 액션 구조에 맞춰 오디오 레이어를 어떻게 분리할지가 중요했음&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;프로젝트 기준&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종 팀 프로젝트 기준은 다음과 같음&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;엔진 : Unreal Engine 5.6
플랫폼 : PC Windows
장르 : 리듬 게임 / 미니게임 컬렉션
레퍼런스 : 리듬세상, Bits &amp;amp; Bops
비주얼 방향 : 귀여운 로우폴리 스타일
메인 컬러 : 보라 / 노랑
핵심 구조 : A / B / C / D 기반 통합 채보 포맷&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨셉은 만능 알바생이 여러 아르바이트를 리듬에 맞춰 성공해나가는 방향임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미니게임 후보는 점프점프, 뒤뚱뒤뚱, 따라쟁이 동물, 주사위 굴리기, 물고기 낚시, 보스 잡기, 칸 칠하기 등이 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 구조에서 중요한 건 각 미니게임이 완전히 따로 노는 게 아니라, 같은 채보 시스템 위에서 서로 다른 액션을 해석한다는 점임&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Wwise 버전 확인&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unreal Engine 5.6에 Wwise를 붙이려면 Wwise Unreal Integration 버전부터 확인해야 함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 확인한 Wwise Unreal Integration 2025.1.4 기준으로 Unreal Engine 5.5, 5.6, 5.7을 지원한다고 되어 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 최종 프로젝트의 엔진 버전인 UE 5.6과 Wwise 조합 자체는 사용할 수 있을 것으로 보임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트에서 중요한 건 최신 기능을 무리하게 쓰는 게 아니라, 입력과 판정과 사운드 피드백이 안정적으로 맞는 구조를 만드는 것임&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Wwise 적용 흐름&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Wwise를 Unreal 프로젝트에 붙이는 흐름은 대략 이렇게 잡으면 될 것 같음&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;Audiokinetic Launcher에서 Unreal 프로젝트 선택
Integrate Wwise in Project 실행
Wwise 프로젝트 생성 또는 기존 Wwise 프로젝트 연결
Wwise에서 SoundBank 설정
Unreal Project Settings에서 WAAPI 연결과 GeneratedSoundBanks 경로 확인
Wwise에서 음악과 효과음 Import
Event 생성
Generate SoundBanks
Unreal Wwise Browser에서 Reconcile
생성된 Wwise UAsset을 Blueprint나 C++에서 사용&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 중요한 건 Wwise에서 Event를 만들었다고 바로 Unreal에서 안정적으로 쓸 수 있는 게 아니라는 점이었음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Wwise 쪽에서 Event와 SoundBank를 만들고, Unreal 쪽에서 Reconcile을 통해 UAsset 상태를 맞춰줘야 함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팀 작업에서는 이 부분이 특히 중요할 것 같음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사운드 담당자가 Wwise에서 Event를 추가했는데 SoundBank Generate나 Reconcile 과정이 빠지면, Unreal에서 해당 이벤트를 못 찾거나 이전 상태의 에셋을 계속 참조할 가능성이 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 Wwise 작업 규칙은 팀 컨벤션으로 정해두는 게 좋아보임&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;Wwise Event 추가
SoundBank Generate
Unreal에서 Reconcile
변경된 Wwise 관련 파일과 UAsset 확인
커밋 전 팀원에게 변경 내용 공유&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;이 프로젝트에서 Wwise가 맡을 역할&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Wwise는 판정 시스템이 아니라 오디오 연출과 피드백을 담당하는 레이어로 두는 게 맞아보임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Wwise가 맡을 역할&lt;/p&gt;
&lt;pre class=&quot;groovy&quot;&gt;&lt;code&gt;BGM 재생
미니게임별 효과음 재생
성공 / 실패 / 콤보 / 결과 사운드 처리
게임 상태에 따른 믹스 변화
박자 / 마디 / 마커 기준 콜백 전달&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Unreal이 맡을 역할&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;채보 데이터 로드
현재 플레이 시간 계산
입력 처리
판정 처리
미니게임별 A / B / C / D 액션 실행
점수와 콤보 계산
UI와 결과 화면 처리&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리듬게임에서 판정은 입력 시간과 채보 이벤트 시간을 비교해야 하므로, Unreal 쪽 JudgeComponent나 ChartSubsystem에서 일관되게 처리하는 게 좋아보임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Wwise는 그 결과를 받아서 소리와 믹스로 피드백을 주는 쪽이 맞음&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;통합 채보 포맷 기준 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 팀 기획의 핵심은 모든 미니게임이 같은 채보 포맷을 공유한다는 점임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 미니게임마다 화면 구성이나 입력 방식은 달라도, 채보는 A / B / C / D 액션 이벤트로 통일됨&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들면 이런 식임&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;점프점프
A : 왼쪽 캐릭터 점프
B : 가운데 캐릭터 점프
C : 오른쪽 캐릭터 점프
D : 미배정

뒤뚱뒤뚱
A : 왼발 착지
B : 오른발 착지
C : 왼발 착지
D : 오른발 착지

따라쟁이 동물
A : 동작 1
B : 동작 2
C : 동작 3
D : 동작 4

주사위 굴리기
A : 왼쪽 이동
B : 위 이동
C : 오른쪽 이동
D : 아래 이동&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 코드 구조로 옮기면 채보 시스템은 액션 이벤트만 발생시키고, 각 미니게임은 그 액션을 자기 규칙에 맞게 해석하는 방식이 될 것 같음&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;ChartSubsystem
- 현재 시간에 맞는 Chart Event 발생

Minigame Actor
- A / B / C / D 이벤트 수신
- 미니게임별 액션으로 변환

JudgeComponent
- 입력 시간과 Chart Event 시간 비교
- Perfect / Good / Miss 판정

AudioSubsystem
- 판정 결과에 맞는 Wwise Event 재생&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 미니게임이 늘어나도 채보 로더와 판정 시스템을 매번 새로 만들 필요가 없음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;새 미니게임은 A / B / C / D를 어떻게 해석할지만 구현하면 됨&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Chart Data 구조 구상&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채보 파일은 JSON 기반 .rhythmchart 포맷으로 저장하기로 되어 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 구조는 최대한 단순하게 잡는 게 좋아보임&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;songId&quot;: &quot;jump_01&quot;,
  &quot;bpm&quot;: 120,
  &quot;offsetMs&quot;: 0,
  &quot;minigame&quot;: &quot;Jump&quot;,
  &quot;events&quot;: [
    {
      &quot;timeMs&quot;: 1000,
      &quot;beat&quot;: 2,
      &quot;action&quot;: &quot;A&quot;,
      &quot;cue&quot;: &quot;Input&quot;,
      &quot;extra&quot;: 0
    }
  ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 action은 A / B / C / D 중 하나가 되고, cue는 이벤트 성격을 구분하는 값으로 둘 수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 Input만 있어도 되지만, 따라쟁이 동물 같은 미니게임은 Preview와 Input이 나뉘어야 할 가능성이 있음&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;Preview : 먼저 보여주는 동작이나 소리
Input   : 플레이어가 입력해야 하는 타이밍&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;extra 값은 미니게임별 추가 정보에 사용할 수 있을 것 같음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 주사위 굴리기에서는 타일 크기나 이동 속도 배율을 넣을 수 있고, 보스 잡기에서는 공격 강도나 패턴 ID를 넣을 수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음부터 너무 복잡하게 만들기보다는 고정 BPM, offsetMs, A / B / C / D 이벤트부터 안정화하는 게 좋을 것 같음&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;시간 기준과 판정 구조&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리듬게임에서 가장 조심해야 할 부분은 시간 기준임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tick에서 DeltaTime을 누적해서 현재 시간을 만드는 방식은 간단하지만, 프레임 드랍이나 일시정지 상황에서 실제 음악과 어긋날 수 있음&lt;/p&gt;
&lt;pre class=&quot;makefile&quot;&gt;&lt;code&gt;CurrentSongTime += DeltaTime;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 ChartSubsystem은 단순 Tick 누적만 믿지 않고, 곡 시작 시점과 보정값을 기준으로 현재 시간을 계산해야 함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 생각한 방향은 이쪽임&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;Wwise에서 BGM 재생
ChartSubsystem에서 곡 시작 기준 시간 저장
현재 플레이 시간 계산
Chart Event 시간과 현재 플레이 시간 비교
입력 발생 시 가장 가까운 이벤트 탐색
판정 결과를 AudioSubsystem과 UI로 전달&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Wwise의 Music Sync Callback이나 Marker는 박자, 마디, 구간 전환 확인에 사용할 수 있을 것 같음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 모든 판정을 Wwise Callback에 의존하는 구조는 피하는 게 좋아보임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 프로젝트는 미니게임마다 액션 해석이 다르고, 따라쟁이 동물처럼 미리 보여준 뒤 한 박자 쉬고 입력하는 구조도 있기 때문에 판정 기준은 Unreal 쪽에서 일관되게 관리하는 편이 안전해보임&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;판정 레이턴시 보정&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기획서 설정 화면에 판정 레이턴시 &amp;plusmn;100ms와 보정 게임이 들어가 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 단순 옵션이 아니라 리듬게임의 핵심 기능으로 봐야 할 것 같음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PC 환경에서도 키보드, 모니터, 스피커, 이어폰, 블루투스 장비에 따라 입력감과 청각 싱크가 달라질 수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 판정식에는 처음부터 보정값을 넣어야 함&lt;/p&gt;
&lt;pre class=&quot;ini&quot;&gt;&lt;code&gt;JudgedDeltaMs = InputTimeMs - TargetTimeMs - UserLatencyOffsetMs - ChartOffsetMs&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;TargetTimeMs는 채보 이벤트 시간이고, InputTimeMs는 플레이어 입력이 들어온 시점의 플레이 시간임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UserLatencyOffsetMs는 설정에서 사용자가 조정한 값이고, ChartOffsetMs는 곡이나 채보 자체의 보정값으로 볼 수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 판정 범위는 임시로 이렇게 둘 수 있을 것 같음&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;Perfect : &amp;plusmn;30ms
Good    : &amp;plusmn;70ms
Miss    : 그 외&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 수치는 확정값이 아니라 테스트하면서 조정해야 함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 미니게임마다 체감 난이도가 다를 수 있어서, 공통 판정 범위와 미니게임별 보정값을 나눌지 고민이 필요해보임&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;미니게임별 Wwise 적용 구상&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;점프점프&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;점프점프는 세 캐릭터가 각자 다른 속도로 제자리 점프를 반복하고, 땅에 닿는 타이밍에 키를 누르는 미니게임임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;채보 이벤트는 캐릭터 착지 타이밍으로 보면 될 것 같음&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;A : 왼쪽 캐릭터 착지
B : 가운데 캐릭터 착지
C : 오른쪽 캐릭터 착지&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Wwise는 성공 착지음, 실패음, 캐릭터별 작은 점프음을 나눠서 쓸 수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중요한 건 착지 사운드가 입력 피드백을 방해하지 않게 짧고 명확해야 한다는 점임&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;뒤뚱뒤뚱&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뒤뚱뒤뚱은 발바닥 간격이 비트 간격이 되는 구조라서 시각 채보와 리듬이 강하게 연결됨&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;A / C : 왼발 착지
B / D : 오른발 착지&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;성공하면 발자국이 지워지고 귀여운 뿅뿅 사운드가 나오는 식으로 피드백을 줄 수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실패하면 발자국이 남고 헛디디는 사운드와 애니메이션을 같이 재생하면 될 것 같음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 미니게임은 효과음이 너무 튀면 박자감을 방해할 수 있으니, BGM과 효과음 볼륨 밸런스를 특히 조심해야 함&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;따라쟁이 동물&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라쟁이 동물은 오른쪽 동물이 먼저 소리와 동작을 보여주고, 한 박자 쉰 뒤 Start UI가 뜨면 왼쪽 동물로 따라하는 구조임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 미니게임은 단순 Input 이벤트만으로는 부족할 수 있음&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;PreviewCue : 오른쪽 동물이 먼저 보여주는 타이밍
InputCue   : 플레이어가 따라해야 하는 타이밍&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;A / B / C / D는 동작 종류로 쓰고, cue 값으로 Preview인지 Input인지 나누는 방식이 좋아보임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Wwise는 PreviewCue에서 동물 울음소리나 동작 소리를 먼저 재생하고, InputCue에서는 입력 결과에 따라 성공 / 실패 사운드를 재생하면 됨&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;주사위 굴리기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주사위 굴리기는 방향 입력이 핵심이라 A / B / C / D가 방향에 대응됨&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;A : 왼쪽
B : 위
C : 오른쪽
D : 아래&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기획서에 타일 크기에 따른 변속 구간이 있으므로, extra 값으로 타일 크기나 속도 배율을 넘기는 방식을 생각해볼 수 있음&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;extra : tileScale 또는 speedMultiplier&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Wwise는 주사위가 굴러가는 반복 사운드와 타일에 도착하는 착지 사운드를 분리하면 좋을 것 같음&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;물고기 낚시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물고기 낚시는 완곡 성공 시 물고기를 낚아올리고, 실패하면 놓치는 구조임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;점수 대신 물고기 길이를 결과로 보여줄 수 있다는 점이 특징임&lt;/p&gt;
&lt;pre class=&quot;gauss&quot;&gt;&lt;code&gt;정확도가 높을수록 물고기 길이 증가
Miss가 많을수록 물고기가 도망갈 확률 증가
완곡 성공 시 낚아올리기 연출
실패 시 놓치기 연출&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Wwise는 물속 분위기, 낚싯줄 당기는 소리, 성공 시 첨벙 소리 등을 담당하면 될 것 같음&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;보스 잡기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보스 잡기는 리듬에 맞춰 패링하면 몬스터 체력이 깎이는 형태임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서는 판정 결과가 바로 전투 피드백으로 이어져야 함&lt;/p&gt;
&lt;pre class=&quot;ada&quot;&gt;&lt;code&gt;Perfect : 강한 패링 사운드와 큰 타격 이펙트
Good    : 약한 타격 사운드
Miss    : 피격 사운드와 화면 흔들림&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Wwise RTPC를 사용하면 BossHP를 넘겨서 체력이 낮아질수록 음악을 고조시키는 것도 가능할 것 같음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이건 기본 판정과 체력 감소 구조가 잡힌 뒤에 적용하는 게 좋아보임&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Wwise Event 이름 규칙&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팀 프로젝트에서는 이름 규칙을 빨리 정해두는 게 중요함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에 대충 만들면 나중에 Wwise Browser에서 이벤트 찾는 시간이 길어질 것 같음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기 규칙은 이렇게 잡을 수 있을 것 같음&lt;/p&gt;
&lt;pre class=&quot;mipsasm&quot;&gt;&lt;code&gt;BGM
Play_BGM_Game
Stop_BGM_Game
Pause_BGM_Game
Resume_BGM_Game

UI
Play_UI_Select
Play_UI_Confirm
Play_UI_Cancel
Play_UI_Result

Judge
Play_Judge_Perfect
Play_Judge_Good
Play_Judge_Miss
Play_Combo_Break

Minigame
Play_Jump_Land
Play_Waddle_Step
Play_CopyAnimal_Voice
Play_Dice_Roll
Play_Fishing_Reel
Play_Boss_Parry&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공통 판정 사운드는 Judge 그룹으로 두고, 미니게임 고유 사운드는 Minigame 쪽으로 분리하는 게 좋아보임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 해두면 점프점프에서만 쓰는 소리와 모든 미니게임에서 공통으로 쓰는 소리를 구분하기 쉬움&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;RTPC와 State 사용 범위&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Wwise의 RTPC와 State는 게임 상태를 사운드에 반영하는 데 사용할 수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 1차 구현부터 너무 많이 넣으면 구현 범위가 커질 수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기에는 이 정도만 고려하면 될 것 같음&lt;/p&gt;
&lt;pre class=&quot;markdown&quot;&gt;&lt;code&gt;RTPC_Combo
RTPC_Accuracy
RTPC_BossHP

State_Gameplay
- Title
- Select
- Playing
- Result

Switch_Minigame
- Jump
- Waddle
- CopyAnimal
- Dice
- Fishing
- Boss&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RTPC_Combo는 콤보가 올라갈수록 효과음의 밝기나 관객 소리를 조금 키우는 데 사용할 수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RTPC_Accuracy는 결과 화면이나 피버 연출과 연결할 수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RTPC_BossHP는 보스전에서 체력에 따라 음악 분위기를 바꾸는 데 사용할 수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이 프로젝트는 귀엽고 직관적인 미니게임 감각이 중요하므로, 사운드 연출이 입력 타이밍을 방해하면 안 됨&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;구현 구조 초안&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종 프로젝트에서 사용할 수 있는 구조를 생각해보면 다음과 같음&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot;&gt;&lt;code&gt;URhythmAudioSubsystem
- Wwise Event 재생
- BGM 시작 / 정지 / 일시정지
- Wwise Callback 수신
- RTPC / State / Switch 전달

URhythmChartSubsystem
- .rhythmchart 로드
- 현재 플레이 시간 계산
- Chart Event 발생
- Offset 적용

URhythmJudgeComponent
- 입력 시간 기록
- 가장 가까운 Chart Event 탐색
- Perfect / Good / Miss 계산

IRhythmMinigameInterface
- A / B / C / D 액션 수신
- 미니게임별 성공 / 실패 연출 처리

URhythmResultComponent
- 점수
- 콤보
- 판정 통계
- 최고점수 갱신 여부&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AudioSubsystem과 ChartSubsystem은 역할을 분리해야 할 것 같음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AudioSubsystem은 Wwise와 연결되는 부분을 담당하고, ChartSubsystem은 채보와 판정 기준 시간을 담당함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JudgeComponent는 ChartSubsystem에서 제공하는 이벤트를 기준으로 입력을 비교하고, 판정 결과를 UI와 AudioSubsystem에 전달하는 방식이 좋아보임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 Wwise 설정이 바뀌어도 판정 로직이 크게 흔들리지 않고, 미니게임이 늘어나도 공통 구조를 유지할 수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>언리얼 7기 본캠프</category>
      <author>주철민</author>
      <guid isPermaLink="true">https://jcmtil.tistory.com/109</guid>
      <comments>https://jcmtil.tistory.com/109#entry109comment</comments>
      <pubDate>Mon, 18 May 2026 08:58:09 +0900</pubDate>
    </item>
    <item>
      <title>260508 TIL - QA 프로젝트 후기</title>
      <link>https://jcmtil.tistory.com/107</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;QA 프로젝트로 제공받았던 프로젝트를 제대로 가동할 수 없는 상황이라, 2주차에 공개된 버그 목록을 '버그 리포트를 제공받았다'고 생각하고 진행했음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1168&quot; data-origin-height=&quot;694&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rEAWV/dJMcabqDNaZ/6ylNck7lPSqE2BUqWQmaW1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rEAWV/dJMcabqDNaZ/6ylNck7lPSqE2BUqWQmaW1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rEAWV/dJMcabqDNaZ/6ylNck7lPSqE2BUqWQmaW1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrEAWV%2FdJMcabqDNaZ%2F6ylNck7lPSqE2BUqWQmaW1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1168&quot; height=&quot;694&quot; data-origin-width=&quot;1168&quot; data-origin-height=&quot;694&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대부분의 경우에 수정할만한 부분들이 코드적으로 어렵지는 않았지만(단순히 조건이 뒤집혀있거나 일부러 반환코드를 넣어두거나 아예 함수 자체가 비어있는 경우도 있었음) 이 버그가 실제로 어디서 연루되었고 어느 파일에 관련된 부분이 있는지 체크하는것이 생각보다 어려웠음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;a href=&quot;https://github.com/Nichipe-JCM/CH5_QA_AO_JCM&quot;&gt;https://github.com/Nichipe-JCM/CH5_QA_AO_JCM&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778462826279&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - Nichipe-JCM/CH5_QA_AO_JCM: 챕터5 QA 프로젝트&quot; data-og-description=&quot;챕터5 QA 프로젝트. Contribute to Nichipe-JCM/CH5_QA_AO_JCM development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/Nichipe-JCM/CH5_QA_AO_JCM&quot; data-og-url=&quot;https://github.com/Nichipe-JCM/CH5_QA_AO_JCM&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://github.com/Nichipe-JCM/CH5_QA_AO_JCM&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/Nichipe-JCM/CH5_QA_AO_JCM&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - Nichipe-JCM/CH5_QA_AO_JCM: 챕터5 QA 프로젝트&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;챕터5 QA 프로젝트. Contribute to Nichipe-JCM/CH5_QA_AO_JCM development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래도 제공된 정답을 보니 전부 정답이긴 했고, 이번 프로젝트를 통해 코드를 읽는 눈과, 클래스끼리 연관된 부분을 찾아서 연결고리를 읽어내고 빠진 부분을 체크하는 실력이 많이 늘었음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✅ Keep : 잘 됐던 것, 다음에도 유지할 것&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;버그 수정 자체는 난이도가 크게 어렵지 않았어서 잘 수정할 수 있었음. 팀프로젝트에서 버그 수정했던 경험이 도움이 많이 되었음&lt;/li&gt;
&lt;li&gt;코드와 연관된 부분 및 코드 검색을 통해 어디와 연결되는지 빠르게 파악해서 코드 자체가 어디가 문제인지 알아내는 과정이 매끄럽게 진행되었음&lt;/li&gt;
&lt;li&gt;버그 수정에 있어서 LLM에게 기대지 않았음&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;⚠️ Problem : 아쉬웠던 것, 문제가 됐던 것&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;프로젝트가 너무 크고 로딩도 오래걸려서 실제로 테스트를 많이 해보지 못했음&lt;/li&gt;
&lt;li&gt;멀티플레이 테스트를 해야하는데 2개 이상 클라를 스탠드얼론으로 띄우면 렉이 너무 심해서 정상적인 테스트가 거의 불가능&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  Try : 다음에 시도할 것, 개선 방향&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;정상적인 테스트 환경을 구축해 테스트가 지장없이 똑바로 이뤄질 수 있게 할 것&lt;/li&gt;
&lt;li&gt;테스트케이스를 조금 더 착실하게 정리하고, 시간을 투자해 제대로 테스트해볼 것&lt;/li&gt;
&lt;li&gt;LLM을 활용해서 조금 더 빠르게 코드 자체를 분석하되, 결과물을 맹신하지 않을 것&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>언리얼 7기 본캠프</category>
      <author>주철민</author>
      <guid isPermaLink="true">https://jcmtil.tistory.com/107</guid>
      <comments>https://jcmtil.tistory.com/107#entry107comment</comments>
      <pubDate>Fri, 8 May 2026 20:28:27 +0900</pubDate>
    </item>
    <item>
      <title>260507 TIL - 양과 늑대 문제와 이진트리 백트래킹 최적화</title>
      <link>https://jcmtil.tistory.com/106</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;어제 심화반에서 풀었던 '양과 늑대' 문제에서, 풀었던 방법과 문제점, 극한 케이스의 경우에 대해 알아보려 함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://school.programmers.co.kr/learn/courses/30/lessons/92343&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://school.programmers.co.kr/learn/courses/30/lessons/92343&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778112097023&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;프로그래머스&quot; data-og-description=&quot;SW개발자를 위한 평가, 교육의 Total Solution을 제공하는 개발자 성장을 위한 베이스캠프&quot; data-og-host=&quot;programmers.co.kr&quot; data-og-source-url=&quot;https://school.programmers.co.kr/learn/courses/30/lessons/92343&quot; data-og-url=&quot;https://programmers.co.kr/&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/h90MC/dJMb8957E3r/RbCiNCbdzkKZp4dfZSGx11/img.png?width=1920&amp;amp;height=960&amp;amp;face=0_0_1920_960,https://scrap.kakaocdn.net/dn/3GHpb/dJMb81fWGu4/4IGbb7FTCV2qUDaWgHu1I0/img.png?width=1920&amp;amp;height=960&amp;amp;face=0_0_1920_960&quot;&gt;&lt;a href=&quot;https://school.programmers.co.kr/learn/courses/30/lessons/92343&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://school.programmers.co.kr/learn/courses/30/lessons/92343&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/h90MC/dJMb8957E3r/RbCiNCbdzkKZp4dfZSGx11/img.png?width=1920&amp;amp;height=960&amp;amp;face=0_0_1920_960,https://scrap.kakaocdn.net/dn/3GHpb/dJMb81fWGu4/4IGbb7FTCV2qUDaWgHu1I0/img.png?width=1920&amp;amp;height=960&amp;amp;face=0_0_1920_960');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;프로그래머스&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;SW개발자를 위한 평가, 교육의 Total Solution을 제공하는 개발자 성장을 위한 베이스캠프&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;programmers.co.kr&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 아래 코드가 수업 도중에 풀었던 방식의 코드임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778112478190&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#include &amp;lt;string&amp;gt;
#include &amp;lt;vector&amp;gt;
#include &amp;lt;unordered_map&amp;gt;
#include &amp;lt;algorithm&amp;gt;

using namespace std;

void backtracking(const vector&amp;lt;int&amp;gt;&amp;amp; info, unordered_map&amp;lt;int, vector&amp;lt;int&amp;gt;&amp;gt;&amp;amp; tree, int&amp;amp; answer, int wolf, int sheep, int node, vector&amp;lt;int&amp;gt; candidates){
    //양, 늑대 갱신 후 늑대 수 검증
    int cursheep = info[node] == 0 ? sheep + 1 : sheep;
    int curwolf = info[node] == 1 ? wolf + 1 : wolf;
    if (curwolf &amp;gt;= cursheep) return;
    
    //정답 갱신(현재 양과 기존 최대 양 중 큰 값)
    answer = max(cursheep, answer);
    
    //목적지 후보에서 스스로를 삭제 후 자신에서 출발하는 목적지들을 목적지에 추가
    candidates.erase(remove(candidates.begin(), candidates.end(), node), candidates.end());
    for(int i : tree[node]) candidates.push_back(i);
    
    //목적지를 돌면서 재귀. 목적지를 &amp;amp;가 아닌 복사본으로 넘기며 자연스럽게 함수가 끝나면 소멸되게(백트래킹) 함
    for(int i : candidates)
    {
        backtracking(info, tree, answer, curwolf, cursheep, i, candidates);
    }
}

int solution(vector&amp;lt;int&amp;gt; info, vector&amp;lt;vector&amp;lt;int&amp;gt;&amp;gt; edges) {
    int answer = 0;
    
    //이진트리 연결 정보를 저장
    unordered_map&amp;lt;int, vector&amp;lt;int&amp;gt;&amp;gt; tree;
    for(const vector&amp;lt;int&amp;gt;&amp;amp; v : edges) tree[v[0]].push_back(v[1]);
    
    backtracking(info, tree, answer, 0, 0, 0, {});
    
    return answer;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제 내 이미지를 통해 이 방식의 해설을 덧붙이자면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;717&quot; data-origin-height=&quot;727&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cyLwHv/dJMcaaSKHQJ/0bZEBpK9Kd4vPOBNxRiFLk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cyLwHv/dJMcaaSKHQJ/0bZEBpK9Kd4vPOBNxRiFLk/img.png&quot; data-alt=&quot;파란색 화살표 방향대로 진행시 예제&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cyLwHv/dJMcaaSKHQJ/0bZEBpK9Kd4vPOBNxRiFLk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcyLwHv%2FdJMcaaSKHQJ%2F0bZEBpK9Kd4vPOBNxRiFLk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;717&quot; height=&quot;727&quot; data-origin-width=&quot;717&quot; data-origin-height=&quot;727&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;파란색 화살표 방향대로 진행시 예제&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제의 경우 '&lt;b&gt;노드의 이동제약이 없다&lt;/b&gt;'는 것이 포인트로, 첨부한 이미지처럼 1번에 간 이후 무조건 2번이나 4번으로 가야하는것이 아닌, 8번으로도 이동할 수 있는것이 특징임. 기존 DFS같은 방식을 사용할 경우 여기서 1번에 갈 경우 2번 또는 4번으로 가는것이 불가능하기 때문에, 계속해서 후보가 담긴 vector를 복사해가며 그 후보들을 돌면서 탐색할경우 최대 양을 구할 수 있는 결과값에 도달할 수 있음. 늑대의 수가 양의 수보다 같거나 많아지면 즉시 취소하고 돌아가기때문에 어느정도 pruning도 된다고 볼 수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 지금 코드의 경우 문제가 있는데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;693&quot; data-origin-height=&quot;705&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c15BW7/dJMcaad81f7/A7ScmVmkZElDKfujo4t4OK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c15BW7/dJMcaad81f7/A7ScmVmkZElDKfujo4t4OK/img.png&quot; data-alt=&quot;파란색과 노란색 순서로 진행한 경우. 결과는 동일하지만, 연산을 더 하고있음&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c15BW7/dJMcaad81f7/A7ScmVmkZElDKfujo4t4OK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc15BW7%2FdJMcaad81f7%2FA7ScmVmkZElDKfujo4t4OK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;693&quot; height=&quot;705&quot; data-origin-width=&quot;693&quot; data-origin-height=&quot;705&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;파란색과 노란색 순서로 진행한 경우. 결과는 동일하지만, 연산을 더 하고있음&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첨부한 이미지처럼, 0 -&amp;gt; 8 -&amp;gt; 1이나 0 -&amp;gt; 1 -&amp;gt; 8이나 결과적으로 방문한 노드는 동일하지만, 이 '동일 방문'을 거르는 로직이 없기 때문에 이런식으로 다음 노드 후보를 전부 순회하게 되면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;0 -&amp;gt; 8 -&amp;gt; 1 -&amp;gt; 2 -&amp;gt; ...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;0 -&amp;gt; 8 -&amp;gt; 1 -&amp;gt; 4 -&amp;gt; ...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;0 -&amp;gt; 8 -&amp;gt; 1 -&amp;gt; 7 -&amp;gt; ...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;0 -&amp;gt; 8 -&amp;gt; 1 -&amp;gt; 9 -&amp;gt; ...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;0 -&amp;gt; 1 -&amp;gt; 8 -&amp;gt; 2 -&amp;gt; ...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;0 -&amp;gt; 1 -&amp;gt; 8 -&amp;gt; 4 -&amp;gt; ...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;0 -&amp;gt; 1 -&amp;gt; 8 -&amp;gt; 7 -&amp;gt; ...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;0 -&amp;gt; 1 -&amp;gt; 8 -&amp;gt; 9 -&amp;gt; ...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 방식으로, 계속 같은것이나 다름 없는 후보를 전부 다 순회하게 되며, 불필요한 연산이 늘어나게 되어 낭비가 생김&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로그래머스 결과 검증에는 들어있지 않지만 극단적인 테스트 케이스를 살펴보자면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1254&quot; data-origin-height=&quot;1254&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxgqgr/dJMcafsXu2P/rfVsFvPk0KN3slLcKqq7u0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxgqgr/dJMcafsXu2P/rfVsFvPk0KN3slLcKqq7u0/img.png&quot; data-alt=&quot;노드 최댓값 17, 쫙 퍼진 이진트리, 전부 양&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxgqgr/dJMcafsXu2P/rfVsFvPk0KN3slLcKqq7u0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbxgqgr%2FdJMcafsXu2P%2FrfVsFvPk0KN3slLcKqq7u0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1254&quot; height=&quot;1254&quot; data-origin-width=&quot;1254&quot; data-origin-height=&quot;1254&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;노드 최댓값 17, 쫙 퍼진 이진트리, 전부 양&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778113985471&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;info = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

edges = [[0, 1], [0, 2], [1, 3], [1, 4], [3, 7], [3, 8], [4, 9], [4, 10], [2, 5], [2, 6], [5, 11], [5, 12], [6, 13], [6, 14], [14, 15], [14, 16]]

return = 17&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 지극히 극단적인 케이스를 만나게 될 경우 연산은 심히 곤란해지는데, 답은 간단하게 17이지만 늑대가 하나도 없기 때문에 pruning이 전혀 되지 않고, 모든 노드를 전부 후보에 넣으면서 계속 재귀해야하고, 또 순서가 바뀐 노드들 또한 전부 재귀하면서 밑도끝도없이 횟수가 늘어나게 됨. 계산상으로 약 &lt;b&gt;22.4억번&lt;/b&gt;의 재귀가 이루어지게 됨&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(info를 훑어서 1이 없으면 0 갯수를 그냥 answer로 제출할 수도 있겠지만 그건 넘어간다 치고...)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 프로그래머스에서 테스트 케이스로 이걸 넣고 기존 코드를 제출해보면 시간초과로 터지는걸 볼 수 있음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 개선점으로 넣은것이 set과 vector&amp;lt;bool&amp;gt;을 통해 '현재까지 방문한 노드 조합'을 이미 방문했는지 검증하는 로직임&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1778116117001&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#include &amp;lt;string&amp;gt;
#include &amp;lt;vector&amp;gt;
#include &amp;lt;unordered_map&amp;gt;
#include &amp;lt;algorithm&amp;gt;
#include &amp;lt;set&amp;gt;

using namespace std;

//전역 set으로 seen을 추가
set&amp;lt;vector&amp;lt;bool&amp;gt;&amp;gt; seen;

void backtracking(const vector&amp;lt;int&amp;gt;&amp;amp; info, unordered_map&amp;lt;int, vector&amp;lt;int&amp;gt;&amp;gt;&amp;amp; tree, int&amp;amp; answer, int wolf, int sheep, int node, vector&amp;lt;int&amp;gt; candidates, vector&amp;lt;bool&amp;gt; visited){
    int cursheep = info[node] == 0 ? sheep + 1 : sheep;
    int curwolf = info[node] == 1 ? wolf + 1 : wolf;
    if (curwolf &amp;gt;= cursheep) return;
    
    answer = max(cursheep, answer);
    
    //visited 벡터의 현재 노드값 방문을 true로 한 다음, seen에 현재 visited와 동일한 벡터가 있는지 체크
    //동일한 벡터가 있다면 이 노드 조합은 이미 시도한 적이 있기 때문에, 즉시 return함
    //예 : 0 -&amp;gt; 1 -&amp;gt; 8, 0 -&amp;gt; 8 -&amp;gt; 1은 방문 노드가 (0, 1, 8)로 동일하기에 더이상 연산하지 않고 return
    //이후 seen에 현재 노드까지 추가된 visited를 추가
    visited[node] = true;
    if(seen.count(visited)) return;
    seen.insert(visited);
    
    candidates.erase(remove(candidates.begin(), candidates.end(), node), candidates.end());
    for(int i : tree[node])
    {
        candidates.push_back(i);
    }
    
    for(int i : candidates)
    {
        backtracking(info, tree, answer, curwolf, cursheep, i, candidates, visited);
    }
}

int solution(vector&amp;lt;int&amp;gt; info, vector&amp;lt;vector&amp;lt;int&amp;gt;&amp;gt; edges) {
    int answer = 0;
    unordered_map&amp;lt;int, vector&amp;lt;int&amp;gt;&amp;gt; tree;
    for(const vector&amp;lt;int&amp;gt;&amp;amp; v : edges)
    {
        tree[v[0]].push_back(v[1]);
    }
    
    //info의 크기와 동일한 bool 벡터를 선언
    vector&amp;lt;bool&amp;gt; visited(info.size(), false);
    
    backtracking(info, tree, answer, 0, 0, 0, {}, visited);
    
    return answer;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식을 통하면 이미 방문했던 노드 조합에서는 더이상 재귀가 진행되지 않기 때문에, 메모리는 좀 더 많이 들지만(복사할 vector&amp;lt;bool&amp;gt;이 생겼고 이걸 계속 set에 넣기 때문에) 재귀 횟수가 약 &lt;b&gt;22.4억&lt;/b&gt;에서 약 &lt;b&gt;6천&lt;/b&gt;번으로 대폭 줄어들게 됨&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런식으로 이미 정답처리가 되었다고 안주하지 않고 혹여나 생길 수 있는 문제를 검토해보는것이 개인적으로 많은 도움이 될 것이라 생각하고, 또 재밌기도 하니 종종 해보려고 함&lt;/p&gt;</description>
      <category>언리얼 7기 본캠프</category>
      <author>주철민</author>
      <guid isPermaLink="true">https://jcmtil.tistory.com/106</guid>
      <comments>https://jcmtil.tistory.com/106#entry106comment</comments>
      <pubDate>Thu, 7 May 2026 10:36:04 +0900</pubDate>
    </item>
    <item>
      <title>260501 TIL - 챕터5 1주차</title>
      <link>https://jcmtil.tistory.com/105</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;컨디션 관리를 잘 했어야 하는데 챕터4 팀프로젝트에 너무 많은 전력을 쏟아낸 나머지 일주일동안 거의 죽은듯이 뭘 하질 못해서 TIL로 적을 내용도 별로 없었음. 심신을 다잡고 다시 적어보려고 함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777632899999&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//이중우선순위큐 문제. multiset을 쓰는 방법과 실제로 priority queue 두개를 쓰는 방법이 있었는데
//각각 장단점이 있었기에 일단 그나마 익숙한 pq 2개를 사용하는 방식으로 풀어봄

#include &amp;lt;string&amp;gt;
#include &amp;lt;vector&amp;gt;
#include &amp;lt;queue&amp;gt;
#include &amp;lt;unordered_map&amp;gt;

using namespace std;

vector&amp;lt;int&amp;gt; solution(vector&amp;lt;string&amp;gt; operations) {
    priority_queue&amp;lt;int&amp;gt; maxpq;
    priority_queue&amp;lt;int, vector&amp;lt;int&amp;gt;, greater&amp;lt;int&amp;gt;&amp;gt; minpq;
    unordered_map&amp;lt;int, int&amp;gt; validcount;
    
    for(const string&amp;amp; s : operations)
    {
        char op = s[0];
        int num = stoi(s.substr(2));
        
        if (op == 'I')
        {
            maxpq.push(num);
            minpq.push(num);
            validcount[num]++;
        }
        else
        {
            if (num == 1)
            {
                while(!maxpq.empty() &amp;amp;&amp;amp; validcount[maxpq.top()] == 0) maxpq.pop();
                if (!maxpq.empty())
                {
                    validcount[maxpq.top()]--;
                    maxpq.pop();
                }
            }
            else if (num == -1)
            {
                while(!minpq.empty() &amp;amp;&amp;amp; validcount[minpq.top()] == 0) minpq.pop();
                if (!minpq.empty())
                {
                    validcount[minpq.top()]--;
                    minpq.pop();
                }
            }
        }
    }
    
    while(!maxpq.empty() &amp;amp;&amp;amp; validcount[maxpq.top()] == 0) maxpq.pop();
    while(!minpq.empty() &amp;amp;&amp;amp; validcount[minpq.top()] == 0) minpq.pop();
    if (maxpq.empty() &amp;amp;&amp;amp; minpq.empty()) return {0, 0};
    else return {maxpq.top(), minpq.top()};
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일주일간 CS 면접 질문들 중 일부&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Q. SOLID 원칙 중 하나를 골라서, 자신이 이전에 했던 프로젝트에서 어떻게 적용했는지 설명&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;A. SOLID중 단일 책임 원칙(SRP)을 준수하려고 했었으며, C++로 만드는 텍스트 RPG 프로젝트에서 게임의 중추를 관리하는 GameManager과 상태 관리하는 StatusManager, 업적을 AchievementManager 등으로 분리해서, 각각이 오직 자신만의 책임을 지고 관리하게끔 만들었습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Q. 객체지향에서 is-a 와 has-a에 대한 설명, 그리고 프로젝트에서 이걸 어떻게 적용해서 해봤었는지 설명&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;A. is-a는 상속관계이며, 결합도가 높고 유연성이 낮지만 다형성을 이용해 하나의 포인터나 참조자로 여러 자식 객체를 관리할 수 있고, 공통된 기능을 부모에 적힌 코드 하나로 재사용할 수 있습니다. has-a는 포함관계이며, 유연성이 높고 결합도가 낮아 클래스마다 한가지 책임만 가지게 할 수 있지만, 포함된 객체가 많아지면 관리 복잡도가 늘어나며 생성 및 소멸에서 메모리 관리에 더 신경을 써야하는 단점이 있습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;언리얼로 만든 협동 디펜스 게임에서, is-a로는 공통된 스킬 베이스 클래스 하나를 두고, 그 클래스를 상속받아 ActiveAbility나 EndAbility등의 공통된 부모 함수를 오버라이드해서 각자의 고유한 스킬을 만들 수 있게 적용시켜 봤습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;has-a는 캐릭터에 붙여서 사용하는 다양한 컴포넌트를 캐릭터에 붙였고, 인벤토리 컴포넌트, 상호작용 컴포넌트등 다양한 컴포넌트를 붙여서 사용할 수 있었습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>언리얼 7기 본캠프</category>
      <author>주철민</author>
      <guid isPermaLink="true">https://jcmtil.tistory.com/105</guid>
      <comments>https://jcmtil.tistory.com/105#entry105comment</comments>
      <pubDate>Fri, 1 May 2026 20:56:56 +0900</pubDate>
    </item>
    <item>
      <title>260424 TIL - 챕터 4 팀프로젝트 끝, KPT 회고</title>
      <link>https://jcmtil.tistory.com/104</link>
      <description>&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;SagoMagic.png&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;853&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dpIfsX/dJMcab42qXN/PXgZQWQmoCmvatnrycMkyk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dpIfsX/dJMcab42qXN/PXgZQWQmoCmvatnrycMkyk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dpIfsX/dJMcab42qXN/PXgZQWQmoCmvatnrycMkyk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdpIfsX%2FdJMcab42qXN%2FPXgZQWQmoCmvatnrycMkyk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;687&quot; height=&quot;458&quot; data-filename=&quot;SagoMagic.png&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;853&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;프로젝트 개요&lt;/h2&gt;
&lt;table style=&quot;color: #333333; text-align: start; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;프로젝트명&lt;/td&gt;
&lt;td&gt;SagoMagic (Co-op 탑다운 타워디펜스)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;엔진&lt;/td&gt;
&lt;td&gt;Unreal Engine 5.6, C++ / Blueprint&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;네트워크&lt;/td&gt;
&lt;td&gt;Dedicated Server, Seamless Travel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;핵심 기술&lt;/td&gt;
&lt;td&gt;GAS, GMS, 그리드 건축, 격자형 인벤토리, Fragment 패턴&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;팀 규모&lt;/td&gt;
&lt;td&gt;7인 (&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;곽은서, 김현, 박원종, 이준로, 임영택, 주철민, 허태린&lt;/span&gt;&amp;nbsp;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;개발 기간&lt;/td&gt;
&lt;td&gt;4 / 1 ~ 4 / 24&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;게임 루프는 로비 &amp;rarr; 정비 단계(BuildState) &amp;rarr; 웨이브 전투(CombatState) &amp;rarr; 결과 화면(ResultState)의 사이클을 반복하며 베이스캠프(ASMBaseCampActor)의 HP가 0이 되면 패배, 마지막 웨이브를 클리어하면 승리한다.4인 Dedicated Server 구조로 운영되며 맵 전환은 Seamless Travel(L_Transition 경유)로 처리했다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Keep - 잘 된 것, 유지할 것&lt;/h2&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;1. GAS 기반 전투&amp;middot;건축 시스템의 통합 설계&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;850&quot; data-origin-height=&quot;378&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bAHhba/dJMcaad0MU1/mTZ1k8CCMcv1elKZMXmHNk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bAHhba/dJMcaad0MU1/mTZ1k8CCMcv1elKZMXmHNk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bAHhba/dJMcaad0MU1/mTZ1k8CCMcv1elKZMXmHNk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbAHhba%2FdJMcaad0MU1%2FmTZ1k8CCMcv1elKZMXmHNk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;654&quot; height=&quot;291&quot; data-origin-width=&quot;850&quot; data-origin-height=&quot;378&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이번 프로젝트에서 가장 큰 기술적 성과는 GAS를 단순히 &quot;스킬 시스템&quot;으로 국한하지 않고, 전투&amp;middot;건축&amp;middot;쿨타임&amp;middot;상태 관리를 아우르는 프로젝트 전체의 공통 인프라로 정착시킨 것이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;어빌리티 시스템 컴포넌트(SMAbilitySystemComponent)는 플레이어, 몬스터, 건물, 베이스캠프 각각에 독립적인 AttributeSet을 붙여 관리한다. USMPlayerAttributeSet은 플레이어의 HP와 이동 속도를, USMMonsterAttributeSet은 몬스터 스탯을, USMBuildingAttributeSet은 건물 내구도를, USMBaseCampAttributeSet은 베이스캠프 HP를 전담한다. 이처럼 어트리뷰트를 객체 타입별로 분리함으로써 각 객체의 상태 변화가 다른 객체에 간섭하지 않으며, GE(GameplayEffect)의 적용 대상을 태그 기반으로 명확히 구분할 수 있었다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;스킬 실행 흐름은 다음과 같이 정형화되어 있다. 마우스 클릭이 TryActivateAbilitiesByTag()를 호출하면GA_SkillBase::ActivateAbility()가 실행되고, 먼저 인벤토리에서 FSMCompiledSkillSummary를 로드해 최종 수치(피해량, 쿨타임, 범위, 지속시간, 업그레이드 태그)를 확보한다. 이후 CommitAbility()로 GE_SkillCooldown을 적용하고, 몽타주를 재생하여 AN_SendEvent AnimNotify가 발동되는 타이밍에 GameplayEvent를 전달한다. 클라이언트는 이 시점에 마우스 좌표를 수집해 ServerSetReplicatedTargetData로 서버에 전송하며, 서버는 OnTargetDataReadyCallback()에서 실제 피해를 적용한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;건물 배치(GA_BuildPlace) 역시 동일한 어빌리티 파이프라인으로 처리된다. ServerRPC_RequestPlaceBuilding() 호출 후 GA_BuildPlace::ActivateAbility()가 서버에서만 실행되어 셀 점유 검증 &amp;rarr; GE로 골드 차감 &amp;rarr; 건물 스폰 + GridData 등록 순서로 원자적으로 처리된다. 스킬 발동과 건물 배치가 동일한 어빌리티 인터페이스를 공유하기 때문에, 어빌리티 등록/해제, 쿨타임 처리, 태그 기반 발동 차단 등의 공통 로직을 중복 없이 재사용할 수 있었다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;쿨타임 관리는 GE_SkillCooldown(HasDuration)과 SetByCaller(Data.Cooldown) 조합으로 동적 주입 방식을 채택했다. GA의 ActivationBlockedTags에 Cooldown.Skill.* 태그를 등록해두면, 해당 GE가 활성화된 동안에는 어빌리티 발동이 자동으로 차단된다. GE의 GrantedTags가 부여/제거되는 시점에 USMSkillCooldownWidget이 실시간으로 갱신되어, 별도의 UI 타이머 로직 없이도 정확한 쿨타임 표시가 가능하다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;2. GMS 기반 UI 디커플링의 구조적 효과&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;멀티플레이어 게임에서 UI 갱신을 서버 &amp;rarr; 클라이언트 복제에 의존하면 불필요한 네트워크 트래픽이 발생하고, 게임 로직과 UI 간의 결합도가 높아진다. 이를 해결하기 위해 GameplayMessageRouter 플러그인 기반의 GameplayMessageSubsystem(GMS)을 UI 갱신의 단일 채널로 채택했다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;구체적인 채널 구조는 다음과 같다. 웨이브 진행 정보는 UI.Event.Wave 채널로 방송되어 USMWaveTimeWidget이 수신하고, 베이스캠프 HP 변화는 USMBaseCampAttributeSet::OnRep_Health()에서 UI.Event.BaseCamp로 방송되어 USMBaseCampHPBarWidget이 수신한다. 알림 메시지는 ClientRPC_ShowNotification()에서 UI.Event.Notification으로 방송되고, 건축 모드 진입/해제는 UI.Event.BuildMode, 편집 모드는 UI.Event.EditMode, 인벤토리 관련 조작은 SM.Message.Inventory.* 채널을 통해 처리된다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 구조의 핵심 이점은 게임 로직 클래스가 어떤 위젯도 직접 참조하지 않는다는 점이다. 예를 들어 ASMBaseCampActor는 HP가 변할 때 GMS에 메시지를 방송하는 것 외에 UI에 대해 아무것도 알지 못한다. 위젯을 교체하거나 추가하더라도 로직 코드를 건드릴 필요가 없다. 실제 개발 과정에서 HUD 구조가 여러 차례 변경되었음에도 게임 로직 측에서의 수정이 전혀 필요 없었던 것이 이를 잘 보여준다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;3. Fragment 패턴 기반 데이터 주도 아키텍처&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;569&quot; data-origin-height=&quot;535&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FCsnC/dJMcabxbF5I/WaQkHZuZpv0cuSZAQHktOK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FCsnC/dJMcabxbF5I/WaQkHZuZpv0cuSZAQHktOK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FCsnC/dJMcabxbF5I/WaQkHZuZpv0cuSZAQHktOK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFCsnC%2FdJMcabxbF5I%2FWaQkHZuZpv0cuSZAQHktOK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;484&quot; height=&quot;455&quot; data-origin-width=&quot;569&quot; data-origin-height=&quot;535&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;인벤토리와 스킬 시스템의 데이터 설계에서 Fragment 패턴을 채택한 것은 이번 프로젝트의 아키텍처적 하이라이트 중 하나다. USMItemDefinition이 필요한 Fragment를 조합하는 방식으로, 아이템 종류마다 클래스를 새로 만들 필요 없이 Fragment 구성만으로 다양한 아이템을 정의할 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;각 Fragment의 역할은 명확하게 분리되어 있다. USMGridShapeFragment는 비트마스크(FSMGridMaskData)로 격자 점유 형태를 정의하며 0&amp;deg;/90&amp;deg;/180&amp;deg;/270&amp;deg; 회전을 지원하고, 배치 충돌 감지도 비트마스크 AND 연산으로 처리되어 루프 기반 충돌 검사 대비 성능이 우수하다. USMAbilityFragment는 아이템과 어빌리티 클래스를 연결하며 관련 태그를 보유한다. USMGemModifierFragment는 젬 효과의 종류&amp;middot;수치&amp;middot;장착 조건 태그(RequiredAllTargetTags, RequiredAnyTargetTags, BlockedTargetTags)를 포함해 태그 기반 호환성 검사를 가능하게 한다. USMInternalInventoryFragment와 USMSkillProgressionFragment는 스킬 아이템의 내부 인벤토리 구조와 레벨업 규칙을 담당한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;스킬 발동 시마다 젬 효과를 재순회하는 대신 FSMCompiledSkillSummary에 수치를 미리 캐싱해두는 설계도 중요한 최적화다. BuildSkillSummary()는 DT_Skill의 베이스 수치에 레벨 증가량을 합산하고, 내부 젬을 순회하며 FinalDamage *= (1 + %), FinalCooldown *= (1 - %) 등의 승산 합성을 적용한 뒤, GrantedBehaviorTags를 BehaviorTags에 병합하여 동작 분기 플래그로 활용한다. 이 캐시는 인벤토리 구성이 바뀔 때만 재계산되므로, 매 어빌리티 발동마다 순회 비용이 발생하지 않는다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;4. 그리드 건축 시스템의 완성도&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;820&quot; data-origin-height=&quot;443&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cHO4nM/dJMcab42qXM/J6kxNJtoZO2rzcBER8wn2K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cHO4nM/dJMcab42qXM/J6kxNJtoZO2rzcBER8wn2K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cHO4nM/dJMcab42qXM/J6kxNJtoZO2rzcBER8wn2K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcHO4nM%2FdJMcab42qXM%2FJ6kxNJtoZO2rzcBER8wn2K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;618&quot; height=&quot;334&quot; data-origin-width=&quot;820&quot; data-origin-height=&quot;443&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;ASMGridManager는 1D 배열(Index = Y &amp;times; Width + X) 기반으로 전체 그리드 상태를 관리한다. 각 셀은 bIsOccupied, BuildingType, OwnerId를 클라이언트에 DOREPLIFETIME으로 복제하고, PlacedActor는 서버 전용으로 유지한다. 이는 건물 액터 자체가 bReplicates = true로 별도 복제되기 때문에 이중 복제를 피하면서도 클라이언트가 그리드 상태를 정확히 인식할 수 있게 하는 효율적인 설계다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;BakeGridHeights()는 StartPlay 시 1회 Landscape의 Z값을 사전 베이크하여, 건물 배치 시마다 지형 높이를 실시간으로 계산하는 비용을 제거한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;경로 기반 펜스 배치에서의 A* 구현은 방향 전환 패널티를 포함하여 직선 경로를 우선시하도록 설계했다. 이를 통해 플레이어가 두 점을 찍으면 꺾이는 경로보다 직선에 가까운 자연스러운 펜스 배치가 이루어진다. 경로의 방향 전환 지점은 GetEffectiveCornerInfo()로 자동 감지되어 ConvertToCornerPreview()를 통해 코너 메시로 교체되고 적절한 Yaw가 계산된다. 경로 끝점에서도 인접 점유 셀을 검사해 접합부 코너를 자동 처리하므로 플레이어가 코너를 수동으로 배치할 필요가 없다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;편집 모드(SMEditModeComponent)에서 건물 이동 시 클라이언트는 로컬에서 선제적으로 이동을 표시하고, ServerRPC_PreviewMove &amp;rarr; MulticastRPC_PreviewMove로 다른 클라이언트에 동기화한다. 이동 확정 시 ServerRPC_MoveBuildings가 원자적으로 검증하여 전체 성공 또는 전체 원위치 처리(all-or-nothing)를 보장한다. 이동 중 Pawn 충돌 채널을 MulticastRPC_SetBuildCollision으로 임시 비활성화해 플레이어가 이동 중인 건물에 막히지 않도록 처리한 것도 세심한 UX 고려였다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;5. 웨이브 시스템의 서버-클라이언트 동기화 설계&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;웨이브 전환 시 가장 까다로운 문제는 서버와 모든 클라이언트의 DataAsset 로드가 완료된 시점에만 전투를 시작해야 한다는 것이었다. 이를 위해 BuildState::Enter() 시점에 USMWaveManagerSubsystem::PreSpawnForWave()가 DT_Monster에서 MonsterDataAsset 경로를 수집하고, AssetManager로 PrimaryAssetId를 조회한 뒤 GameState::SetAssetsToLoad()를 통해 클라이언트에 로드 목록을 복제한다. 서버는 LoadAssetsByIDWithBundles({&quot;Server&quot;}), 클라이언트는 OnRep_AssetsToLoad() 콜백에서 LoadAssetsByIDWithBundles({&quot;Client&quot;})를 비동기로 실행하며, 각 클라이언트가 로드 완료 시 ServerNotifyClientLoadComplete()를 호출해 ReadyClientCount를 증가시킨다. 전원이 준비되면 OnReadyForCombat이 발동되어 CombatState로 전환된다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;CombatState에서 몬스터는 SpawnInterval 간격으로 순차 활성화된다. PreSpawn 단계에서 이미 Hidden 상태로 스폰해 두었기 때문에 전투 시작 시점의 스폰 부하가 없으며, 시각적으로도 몬스터가 갑자기 대량으로 나타나는 현상을 방지할 수 있다. 타임오버 패널티로 잔존 몬스터가 SelfKill하여 베이스캠프에 고정 데미지를 입히는 메커니즘은, 플레이어가 웨이브를 질질 끌어 시간을 낭비하는 전략을 방지하는 게임 디자인적 의도와 서버 측 리소스 정리를 동시에 달성한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;6. 네트워크 보안 및 서버 권한 설계&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;보안 설계에서 세 가지 핵심 원칙을 일관되게 유지했다. 첫째, 이속 핵 방어를 위해 서버가 1초마다 MaxWalkSpeed를 검증하고 허용 오차(110%) 초과 시 강제 복구한다. 둘째, 건축 배치는 클라이언트의 고스트 프리뷰가 순수 로컬 표현에 그치고 실제 배치는 GA_BuildPlace [ServerOnly]에서 이중 검증 후 처리된다. 셋째, 인벤토리의 모든 Add/Move/Drop 조작이 서버에서 Authority 체크를 통과해야만 반영된다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;USMSyncDataManager를 서버 전용으로 제한한 것도 의도적인 설계 제약이다. 클라이언트에서 DataTable 원본에 직접 접근할 수 없으므로 치트 도구를 통한 스탯 조작 시도를 구조적으로 차단한다. 스킬 어빌리티를 ASMPlayerCharacter의 ASC가 아닌 ASMPlayerState의 ASC에 등록한 것도 중요한 설계 결정이다. 캐릭터가 리스폰으로 재생성되더라도 PlayerState는 유지되기 때문에, 부활 후에도 어빌리티 등록 상태와 쿨타임 정보가 그대로 보존된다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Problem - 아쉬웠던 점, 겪었던 문제&lt;/h2&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;1. GAS 초기 설계 비용과 구조 재설계&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;570&quot; data-origin-height=&quot;607&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/sxyax/dJMcab42qXL/rAMiTDoSchvJerPiM7tXy0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/sxyax/dJMcab42qXL/rAMiTDoSchvJerPiM7tXy0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/sxyax/dJMcab42qXL/rAMiTDoSchvJerPiM7tXy0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fsxyax%2FdJMcab42qXL%2FrAMiTDoSchvJerPiM7tXy0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;444&quot; height=&quot;473&quot; data-origin-width=&quot;570&quot; data-origin-height=&quot;607&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;GAS는 초기 아키텍처 결정이 이후 전체 개발 흐름에 미치는 영향이 매우 크다. 이번 프로젝트에서도 AttributeSet 계층 구조와 GameplayEffect 연산 파이프라인을 초반에 확정하는 데 예상보다 많은 시간이 소요되었고, 프로젝트 중반에 일부 Effect 구조를 재설계하는 일이 발생했다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;특히 피해 계산 흐름에서 GE_InstantDamage의 Execution을 통한 계산과 SetByCaller를 통한 직접 수치 주입 방식 사이의 선택을 두고 초반 설계가 흔들렸다. 최종적으로 SetByCaller(Data.Damage.Amount) 방식을 채택했지만, 이 결정이 늦어지면서 일부 어빌리티에서 두 방식이 혼재하는 시기가 있었고 이를 정리하는 데 추가 공수가 들었다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;ActivationBlockedTags와 ActivationRequiredTags의 설계도 초반에는 충분히 체계화되지 않아, 전투 중 건축 모드 진입 차단(CombatState에서 State.Build.Place 태그 차단) 같은 요구사항이 생길 때마다 태그 구조를 추가하는 방식으로 대응했다. 처음부터 게임 상태 전환과 연동되는 태그 계층을 설계 문서로 정리해두었다면 이런 추가 작업을 줄일 수 있었을 것이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;2. 멀티플레이어 복제 및 RPC 부하&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1110&quot; data-origin-height=&quot;394&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dH4hja/dJMcab42qXK/Zr17UrengnPUJKny8aX1Zk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dH4hja/dJMcab42qXK/Zr17UrengnPUJKny8aX1Zk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dH4hja/dJMcab42qXK/Zr17UrengnPUJKny8aX1Zk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdH4hja%2FdJMcab42qXK%2FZr17UrengnPUJKny8aX1Zk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;744&quot; height=&quot;264&quot; data-origin-width=&quot;1110&quot; data-origin-height=&quot;394&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Dedicated Server 환경에서 액터 수가 늘어남에 따라 복제 부하가 누적되는 문제가 있었다. 웨이브 후반부에 ASMMonsterBase 객체가 다수 활성화된 상태에서 ASMMonsterProjectile 스폰, ASMBaseCampActor HP 변화, ASMGridManager 상태 갱신이 동시에 발생하면 클라이언트 측에서 리플리케이션 지연이 체감될 수 있었다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;몬스터 ASC를 Minimal 복제 모드로 설정한 것은 클라이언트 태그 동기화만을 위한 최소한의 복제라는 의도였으나, 실제로는 UGA_MonsterAttackBase 실행 시 GameplayCue의 네트워크 발동이 예상보다 많은 트래픽을 발생시켰다. 원거리 몬스터(Squid)의 투사체는 서버에서S 스폰되어 bReplicates = true로 모든 클라이언트에 복제되는데, 웨이브 후반에 다수의 투사체가 동시에 활성화되면 복제 대역폭 소비가 컸다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;SMEditModeComponent의 건물 이동 미리보기에서 ServerRPC_PreviewMove &amp;rarr; MulticastRPC_PreviewMove 체인이 건물 수만큼 반복 호출될 수 있어, 다수의 건물을 동시에 이동할 때 RPC 호출 빈도가 높아지는 문제도 있었다. 이 부분은 건물 이동 데이터를 배열로 묶어 단일 RPC 호출로 처리하도록 배칭(batching)하지 못한 것이 아쉽다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;3. 몬스터 AI 연산 부하&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1092&quot; data-origin-height=&quot;497&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bJliAs/dJMcaad0MUZ/vMkCbl229eczfeL0Tm75nk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bJliAs/dJMcaad0MUZ/vMkCbl229eczfeL0Tm75nk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bJliAs/dJMcaad0MUZ/vMkCbl229eczfeL0Tm75nk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbJliAs%2FdJMcaad0MUZ%2FvMkCbl229eczfeL0Tm75nk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;735&quot; height=&quot;335&quot; data-origin-width=&quot;1092&quot; data-origin-height=&quot;497&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;ASMMonsterAIController는 BehaviorTree와 타이머 기반 UpdateTargetAndTryAttack()을 함께 운영한다. 웨이브 후반에 수십 개의 몬스터가 동시에 활성화되면 각 AIController의 타이머가 거의 같은 프레임에 집중될 수 있으며, 여기에 FindBuildingInRange(), FindNearestPlayerInRange() 같은 오버랩 탐색이 겹치면 CPU 스파이크가 발생했다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;ASMGridManager::FindPath()의 A* 연산도 그리드 크기와 건물 배치 밀도에 따라 비용이 증가한다. 현재 구현에서는 몬스터의 이동 목표(TargetActor)가 항상 가장 가까운 베이스캠프로 고정되어 있고, 장애물(건물)이 변경될 때마다 경로를 재계산하는 구조인데, 이 재계산이 전투 중에도 빈번하게 발생할 수 있었다. 특히 편집 모드에서 건물 이동이 일어날 때 다수의 몬스터가 동시에 경로를 재계산하는 구간이 문제였다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;USMWaveManagerSubsystem의 틱 처리도 최적화가 부족했다. 서브시스템이 매 틱마다 웨이브 상태를 검사하는 대신, 이벤트 기반으로 전환하거나 틱 간격을 늘렸다면 불필요한 연산을 줄일 수 있었다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;4. GAS Prediction 구현의 미완성&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;현재 구현에서 스킬 발동 시 클라이언트는 GameplayCue의 코스메틱 이펙트만 선예측하고, 실제 피해 처리는 서버에서 OnTargetDataReadyCallback() 이후에 이루어진다. 이 구조는 서버 권한을 보장한다는 측면에서 올바르지만, 네트워크 레이턴시가 높을 때 스킬 이펙트와 실제 피해 판정 사이의 시각적 불일치가 발생할 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;건축 배치는 ServerOnly 어빌리티로 처리되어 클라이언트 예측이 전혀 없다. 클라이언트가 배치 요청을 보낸 후 서버 응답을 받기까지 건물이 나타나지 않는 딜레이가 존재하며, 이것이 빠른 연속 배치 시에는 체감될 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;FPredictionKey 기반의 예측 롤백을 건축과 인벤토리 조작에까지 확장하지 못한 것이 UX 측면에서 아쉬운 부분으로 남았다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;5. 인벤토리 복제와 클라이언트 반응성&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1180&quot; data-origin-height=&quot;383&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dfJnw3/dJMcab42qXJ/mjOxLR7qWuKzr1CJkpvqnK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dfJnw3/dJMcab42qXJ/mjOxLR7qWuKzr1CJkpvqnK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dfJnw3/dJMcab42qXJ/mjOxLR7qWuKzr1CJkpvqnK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdfJnw3%2FdJMcab42qXJ%2FmjOxLR7qWuKzr1CJkpvqnK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;761&quot; height=&quot;247&quot; data-origin-width=&quot;1180&quot; data-origin-height=&quot;383&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;USMInventoryComponent는 COND_OwnerOnly로 복제되어 보안과 트래픽 측면에서는 이상적이지만, 인벤토리 조작의 모든 경로가 서버를 경유해야 한다는 제약이 생긴다. 아이템 이동, 젬 장착, 스킬 레벨업 등 인벤토리 내부 조작 시 서버 왕복 레이턴시만큼 반응이 늦게 느껴질 수 있으며, 이를 보완하는 로컬 예측 레이어가 구현되지 않았다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;비트마스크 기반 충돌 감지는 성능 측면에서는 효율적이지만, FSMGridMaskData의 회전 변환 로직이 복잡해 일부 엣지 케이스(아이템 회전 후 격자 경계 근처 배치)에서 잘못된 충돌 판정이 발생하는 버그가 간헐적으로 재현되었다. 이 문제는 회전 변환 행렬 계산의 부호 처리 오류에서 기인했으나, 프로젝트 기간 내에 완전히 수정하지 못했다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;Try - 앞으로 시도할 것, 개선 방향&lt;/h2&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;1. 대규모 환경 최적화 체계 도입&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;다음 장기 프로젝트에서는 Unreal Engine 5의 대규모 최적화 도구를 본격적으로 활용할 계획이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Significance Manager를 도입하여 카메라 거리, 화면 점유율, 플레이어와의 상호작용 빈도에 따라 몬스터 AI 틱 빈도를 동적으로 조절한다. 예를 들어 화면 밖 원거리 몬스터는 Significance 값이 낮게 설정되어 AI 틱이 0.3~0.5초 간격으로 줄어들고, 플레이어 근처의 몬스터는 정상 빈도로 유지된다. 이 접근만으로도 웨이브 후반 AI 연산 부하의 상당 부분을 줄일 수 있을 것으로 기대한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;A* 경로 계산은 온디맨드 캐싱 전략으로 전환한다. 전투 중에는 그리드 변화(건물 배치/삭제/이동)가 발생할 때만 영향 받는 몬스터 그룹의 경로를 재계산하고, 그 외에는 기존 경로를 재사용한다. 이를 위해 그리드 셀 변경 이벤트를 발행하고, 해당 셀을 경로의 일부로 포함하는 몬스터만 선택적으로 재계산하는 구조가 필요하다. ASMGridManager에 구독 패턴을 추가하면 구현 가능하다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;USMWaveManagerSubsystem의 상태 검사는 틱 기반에서 이벤트 기반으로 전환한다. 몬스터가 사망할 때마다 CheckWaveCleared()를 호출하는 현재 방식은 이미 이벤트 기반에 가깝지만, 타임오버 처리 등 일부 흐름이 틱에 의존하고 있어 이를 타이머 델리게이트로 대체할 것이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;렌더링 측면에서는 World Partition과 HLOD를 도입해 대규모 맵에서 원거리 오브젝트의 렌더링 비용을 줄이고, Occlusion Culling과 Distance Culling을 병행하여 화면에 보이지 않는 건물과 몬스터의 렌더링 스레드 부하를 제거한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;2. GAS Prediction 고도화&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;FPredictionKey 기반의 클라이언트 예측을 스킬 피해 판정을 넘어 건축 배치까지 확장한다. 건물 배치의 경우, 클라이언트가 서버 검증 전에 고스트 프리뷰가 아닌 실제 건물 메시를 로컬에서 즉시 표시하고, 서버 검증 실패 시 롤백하는 방식으로 반응성을 개선할 수 있다. 이를 위해 GA_BuildPlace에 LocalPredicted 활성화 정책을 적용하고, 서버 검증 실패 시 OnAbilityCancelled 콜백에서 클라이언트 측 임시 건물 액터를 제거하는 롤백 로직을 구현해야 한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;인벤토리 조작의 경우 서버 왕복 없이 로컬에서 선예측 상태를 표시하고, 서버 응답이 오면 결과를 확정하거나 원상 복귀하는 Optimistic UI 패턴을 도입한다. GAS의 LocalPredicted 정책을 인벤토리 어빌리티에도 적용하거나, 별도의 클라이언트 측 임시 상태 레이어를 두는 방식으로 구현할 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;몬스터 투사체의 경우, 서버에서만 스폰하고 복제하는 현재 방식 대신 클라이언트에서 발사 시점을 예측하여 로컬 코스메틱 투사체를 생성하고, 피해 판정은 서버에서만 처리하는 방식으로 체감 반응성을 높인다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;3. 복제 전략 세분화&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;ASMGridManager의 GridData 배열은 현재 DOREPLIFETIME 단일 조건으로 전체가 복제된다. 실제로는 변경된 셀만 클라이언트에 전달하면 충분한데, 배열 전체를 복제하면 웨이브 시작 시처럼 다수의 셀이 동시에 변경되는 구간에서 불필요한 트래픽이 발생한다. DOREPLIFETIME_CONDITION과 ReplicatedUsing 콜백을 조합하거나, FastArraySerializer를 활용해 변경된 요소만 델타 전송하도록 개선할 계획이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;몬스터 투사체의 복제도 재검토가 필요하다. 수명이 짧고 수가 많은 투사체 액터를 전통적인 액터 복제로 처리하는 것은 연결/해제 오버헤드가 크다. UReplicationGraph의 UReplicationGraphNode_ActorList를 활용하거나, 투사체 상태를 별도의 경량 구조체 배열로 압축해 GameState를 통해 복제하는 커스텀 복제 전략이 더 효율적일 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;RPC 배칭도 개선 과제다. 편집 모드의 다중 건물 이동처럼 여러 엔티티의 상태를 동시에 변경하는 작업은 단일 RPC에 배열을 담아 전송하도록 통일한다. ServerRPC_MoveBuildings는 이미 이 방식을 사용하고 있으나, 미리보기 단계의 ServerRPC_PreviewMove도 배칭으로 통일해야 한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;4. 프로파일링 기반 개발 문화 정착&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이번 프로젝트에서 성능 문제는 주로 증상이 나타난 후 사후 대응 방식으로 처리했다. 다음 프로젝트에서는 Unreal Insights와 stat unit, stat game, stat net 명령을 개발 초기부터 주기적으로 모니터링하여 병목을 선제적으로 발견하는 습관을 팀 전체에 정착시킨다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;특히 네트워크 프로파일링에서 NetworkProfiler를 활용해 복제 대역폭을 액터 단위로 분석하고, RPC 호출 빈도와 페이로드 크기를 정량적으로 파악하는 과정을 스프린트마다 반복한다. 이를 통해 주관적인 &quot;느린 것 같다&quot;는 판단 대신 수치 기반으로 최적화 우선순위를 결정할 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;GAS 측면에서는 AbilitySystem.DebugAbilities 콘솔 명령과 ShowDebug AbilitySystem 활성화를 통해 어트리뷰트 수치, 활성 Effect, 태그 상태를 실시간으로 확인하는 디버깅 워크플로를 팀 표준으로 수립한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;5. 게임 기획과 기술의 통합적 시각 확보&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;942&quot; data-origin-height=&quot;508&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dGBkpV/dJMcajoouvB/Cp6XK9l0iGafEmfkN18e9K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dGBkpV/dJMcajoouvB/Cp6XK9l0iGafEmfkN18e9K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dGBkpV/dJMcajoouvB/Cp6XK9l0iGafEmfkN18e9K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdGBkpV%2FdJMcajoouvB%2FCp6XK9l0iGafEmfkN18e9K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;562&quot; height=&quot;303&quot; data-origin-width=&quot;942&quot; data-origin-height=&quot;508&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Fragment 패턴과 DataTable 기반의 데이터 주도 아키텍처를 구축해두었기 때문에, 다음 단계는 이 구조를 플레이테스트 피드백 사이클에 실제로 활용하는 것이다. 젬 조합별 스킬 성능 데이터를 로깅하고, 어떤 조합이 실제 플레이에서 지배적인지 분석하는 인게임 통계 시스템을 USMSyncDataManager 위에 구축할 계획이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;밸런스 조정을 위해 코드를 재컴파일할 필요 없이 DT_Skill, DT_Wave, DT_Building 수치를 런타임에 핫리로드하는 기능도 다음 프로젝트에서 구현한다. Unreal Engine의 FTableRowBase 기반 DataTable은 에디터에서 이미 핫리로드를 지원하므로, 이를 패키징된 빌드에서도 외부 JSON으로 오버라이드할 수 있는 레이어를 추가하면 된다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;궁극적으로는 기술 아키텍처가 게임 기획의 실험 속도를 뒷받침하는 구조, 즉 프로그래머의 개입 없이 기획자가 새로운 스킬 조합이나 웨이브 구성을 빠르게 시험하고 검증할 수 있는 파이프라인을 완성하는 것이 목표다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1050&quot; data-origin-height=&quot;633&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bJghVp/dJMcaad0MU0/ghJD3qD6NnYabVUCg0akW1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bJghVp/dJMcaad0MU0/ghJD3qD6NnYabVUCg0akW1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bJghVp/dJMcaad0MU0/ghJD3qD6NnYabVUCg0akW1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbJghVp%2FdJMcaad0MU0%2FghJD3qD6NnYabVUCg0akW1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;691&quot; height=&quot;417&quot; data-origin-width=&quot;1050&quot; data-origin-height=&quot;633&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>언리얼 7기 본캠프</category>
      <author>주철민</author>
      <guid isPermaLink="true">https://jcmtil.tistory.com/104</guid>
      <comments>https://jcmtil.tistory.com/104#entry104comment</comments>
      <pubDate>Fri, 24 Apr 2026 20:34:28 +0900</pubDate>
    </item>
    <item>
      <title>260417 TIL - 월드드랍 UI와 위젯 컴포넌트</title>
      <link>https://jcmtil.tistory.com/102</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 배운 내용이라기보단 만들면서 고생한 시행착오를 수없이 많이 거친것에 대해 이야기하려고 함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만들어야 할 내용은 '바닥에 있는 아이템중 상호작용 대상이 된 아이템에 띄우는 UI' 였고, 이전 팀플에서도 만들어본적이 있어서 크게 어려울거라고 생각하진 않았음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 한줄때문에 28시간을 낭비하기 전까진...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1017&quot; data-origin-height=&quot;971&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cfBK8v/dJMcacCOOsA/nR5BH7KH2HKbulhYN5FskK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cfBK8v/dJMcacCOOsA/nR5BH7KH2HKbulhYN5FskK/img.png&quot; data-alt=&quot;이전 팀프로젝트때 만든 바닥 아이템 UI. 비슷한 느낌으로 갈거라 어려울거란 생각은 안했는데...&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cfBK8v/dJMcacCOOsA/nR5BH7KH2HKbulhYN5FskK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcfBK8v%2FdJMcacCOOsA%2FnR5BH7KH2HKbulhYN5FskK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1017&quot; height=&quot;971&quot; data-origin-width=&quot;1017&quot; data-origin-height=&quot;971&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;이전 팀프로젝트때 만든 바닥 아이템 UI. 비슷한 느낌으로 갈거라 어려울거란 생각은 안했는데...&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;열심히 코드를 짰음. 바닥에 있는 아이템은 그 아이템의 정보를 가진 Payload를 가지고 있으니, 그 Payload에서 Item Definition을 불러오고 거기서 Fragment 시스템을 기반으로 만들었으니 필요한 Fragment를 가져와 출력할 정보를 수집하고 UI랑 연결해주고 UI WBP를 만들어서 채우고... 아무것도 문제될거라 생각하지 않았음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1676&quot; data-origin-height=&quot;973&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bO1bYc/dJMcac3Qsf1/QclznfS09sZktjXbT36aik/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bO1bYc/dJMcac3Qsf1/QclznfS09sZktjXbT36aik/img.png&quot; data-alt=&quot;초기 상태. 딱히 동작에 문제될것도 없고 UI가 안 뜰 이유도 없는 깔끔한 UI임&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bO1bYc/dJMcac3Qsf1/QclznfS09sZktjXbT36aik/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbO1bYc%2FdJMcac3Qsf1%2FQclznfS09sZktjXbT36aik%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1676&quot; height=&quot;973&quot; data-origin-width=&quot;1676&quot; data-origin-height=&quot;973&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;초기 상태. 딱히 동작에 문제될것도 없고 UI가 안 뜰 이유도 없는 깔끔한 UI임&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 막상 위젯 컴포넌트에 위젯을 등록하고 스폰시켜보니까 스폰이 안됨. 여기부터 멘탈이 나가기 시작함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;분명히 위젯 문제 없었고, 컴포넌트 잘 장착돼있고, 로그 찍어보니까 정상적으로 등록이 되었다고 하는데 계속 UI가 안그려지기 시작함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;617&quot; data-origin-height=&quot;530&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bjAwZL/dJMcagZtZg3/uMgfTFGnmDksEZW0KyOk5k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bjAwZL/dJMcagZtZg3/uMgfTFGnmDksEZW0KyOk5k/img.png&quot; data-alt=&quot;외곽선만 보이고 UI가 안나온다...&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bjAwZL/dJMcagZtZg3/uMgfTFGnmDksEZW0KyOk5k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbjAwZL%2FdJMcagZtZg3%2FuMgfTFGnmDksEZW0KyOk5k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;617&quot; height=&quot;530&quot; data-origin-width=&quot;617&quot; data-origin-height=&quot;530&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;외곽선만 보이고 UI가 안나온다...&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때부터 진짜 열몇시간동안 UI에 대해 공식문서 찾아보고 가이드 찾아보고 UI 구조 계속 바꿔보고 코드도 바꿔보고 하면서 별의 별 삽질을 다했음. 진짜 이번 팀플 과정에서 가장 힘들었다고 해도 과언이 아닐 정도&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 원인은 전혀 예상치 못한곳에 있었음..&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;616&quot; data-origin-height=&quot;44&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/S7XxA/dJMcaffgw1W/kEQGQlyuybxT6btkVUK6P0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/S7XxA/dJMcaffgw1W/kEQGQlyuybxT6btkVUK6P0/img.png&quot; data-alt=&quot;문제의 코드 '한 줄'&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/S7XxA/dJMcaffgw1W/kEQGQlyuybxT6btkVUK6P0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FS7XxA%2FdJMcaffgw1W%2FkEQGQlyuybxT6btkVUK6P0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;616&quot; height=&quot;44&quot; data-origin-width=&quot;616&quot; data-origin-height=&quot;44&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;문제의 코드 '한 줄'&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이녀석이 범인이었음. 싱글게임이던 이전 팀플과 달리 '멀티게임인데 tick돌리면 뭔가 연산에 부하가 되지 않을까' 라는 생각(이것도 어이가 없는게, 어차피 UI는 로컬에만 뜨는거라 크게 상관이 없었음. 멍청...) 으로 false로 해뒀었는데, 위젯 컴포넌트는 tick마다 ui를 화면에 계속 출력하며 추적하거나 회전하거나 해야하는데 이게 false로 되어있으니 UI가 애초에 뜰 리가 있나...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;703&quot; data-origin-height=&quot;379&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cQMzR9/dJMcaaSxvHH/d8e5iwUNI1MNOYHo9XNZQK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cQMzR9/dJMcaaSxvHH/d8e5iwUNI1MNOYHo9XNZQK/img.png&quot; data-alt=&quot;테스트용 위젯을 넣고 동작시킨 모습&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cQMzR9/dJMcaaSxvHH/d8e5iwUNI1MNOYHo9XNZQK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcQMzR9%2FdJMcaaSxvHH%2Fd8e5iwUNI1MNOYHo9XNZQK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;703&quot; height=&quot;379&quot; data-origin-width=&quot;703&quot; data-origin-height=&quot;379&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;테스트용 위젯을 넣고 동작시킨 모습&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 수십시간에 걸친 내 고통은 true 한줄로 완벽하게 해결이 되었고, 이 브랜치를 PR 올린 후에 말 그대로 긴장이 풀려서 그대로 뻗어버렸음...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고통스러운 순간이었지만 이런 경험도 결국 피와 살이 될것이라 생각함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;449&quot; data-origin-height=&quot;336&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Pqan9/dJMcaiXg2i1/E7X79gp6n3HNpynDs4bqik/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Pqan9/dJMcaiXg2i1/E7X79gp6n3HNpynDs4bqik/img.png&quot; data-alt=&quot;된다... 된다... 진짜 된다... ㅠㅠ&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Pqan9/dJMcaiXg2i1/E7X79gp6n3HNpynDs4bqik/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPqan9%2FdJMcaiXg2i1%2FE7X79gp6n3HNpynDs4bqik%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;449&quot; height=&quot;336&quot; data-origin-width=&quot;449&quot; data-origin-height=&quot;336&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;된다... 된다... 진짜 된다... ㅠㅠ&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한줄평 : UI 너무 싫다............&lt;/p&gt;</description>
      <category>언리얼 7기 본캠프</category>
      <author>주철민</author>
      <guid isPermaLink="true">https://jcmtil.tistory.com/102</guid>
      <comments>https://jcmtil.tistory.com/102#entry102comment</comments>
      <pubDate>Sat, 18 Apr 2026 06:05:03 +0900</pubDate>
    </item>
    <item>
      <title>260413 TIL - 퀵슬롯 퀵슬롯 퀵슬롯</title>
      <link>https://jcmtil.tistory.com/101</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;저번주랑 주말 내내 퀵슬롯과 복통과 싸우며 많은것을 배웠음. 특히...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;603&quot; data-origin-height=&quot;309&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bgzpMt/dJMcadVUnTw/qA9wfDq5YGlsuHWru7gJX0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bgzpMt/dJMcadVUnTw/qA9wfDq5YGlsuHWru7gJX0/img.png&quot; data-alt=&quot;오른쪽이 드래그 시작한 아이템. 왼쪽의 빨간건 드래그 프리뷰가 생성되는 지점...?&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bgzpMt/dJMcadVUnTw/qA9wfDq5YGlsuHWru7gJX0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbgzpMt%2FdJMcadVUnTw%2FqA9wfDq5YGlsuHWru7gJX0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;603&quot; height=&quot;309&quot; data-origin-width=&quot;603&quot; data-origin-height=&quot;309&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;오른쪽이 드래그 시작한 아이템. 왼쪽의 빨간건 드래그 프리뷰가 생성되는 지점...?&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자꾸 아이템을 드래그하는데 드래그 프리뷰가 계속 이상한곳에서 생성되어서 이걸 해결하려고 정말 많은 정보를 찾아다녔음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식문서도 열심히 찾아보고, AI 닥달도 하고... 그러면서 깨달은 결론은 '지금 구조로는 못고친다' 였음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://dev.epicgames.com/documentation/unreal-engine/creating-drag-and-drop-ui-in-unreal-engine?lang=ko&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://dev.epicgames.com/documentation/unreal-engine/creating-drag-and-drop-ui-in-unreal-engine?lang=ko&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1776080134928&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Creating Drag and Drop UI in Unreal Engine | Unreal Engine 5.7 Documentation | Epic Developer Community&quot; data-og-description=&quot;Shows how you can create drag and droppable UI widgets with UMG.&quot; data-og-host=&quot;dev.epicgames.com&quot; data-og-source-url=&quot;https://dev.epicgames.com/documentation/unreal-engine/creating-drag-and-drop-ui-in-unreal-engine?lang=ko&quot; data-og-url=&quot;https://dev.epicgames.com/documentation/unreal-engine/creating-drag-and-drop-ui-in-unreal-engine&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/ZZbJ8/dJMb81fTVrQ/dO38yfacTibyqLGAk56Hk0/img.png?width=1200&amp;amp;height=675&amp;amp;face=0_0_1200_675,https://scrap.kakaocdn.net/dn/ddjP5U/dJMb9kmd4w8/OOZo9RPKKxki0ZorbYFVg0/img.png?width=1200&amp;amp;height=675&amp;amp;face=0_0_1200_675&quot;&gt;&lt;a href=&quot;https://dev.epicgames.com/documentation/unreal-engine/creating-drag-and-drop-ui-in-unreal-engine?lang=ko&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://dev.epicgames.com/documentation/unreal-engine/creating-drag-and-drop-ui-in-unreal-engine?lang=ko&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/ZZbJ8/dJMb81fTVrQ/dO38yfacTibyqLGAk56Hk0/img.png?width=1200&amp;amp;height=675&amp;amp;face=0_0_1200_675,https://scrap.kakaocdn.net/dn/ddjP5U/dJMb9kmd4w8/OOZo9RPKKxki0ZorbYFVg0/img.png?width=1200&amp;amp;height=675&amp;amp;face=0_0_1200_675');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Creating Drag and Drop UI in Unreal Engine | Unreal Engine 5.7 Documentation | Epic Developer Community&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Shows how you can create drag and droppable UI widgets with UMG.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;dev.epicgames.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고한 공식 문서 링크&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;456&quot; data-origin-height=&quot;511&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BslRp/dJMcajofKfX/keLUcuPOAl6RQl8P8Va8JK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BslRp/dJMcajofKfX/keLUcuPOAl6RQl8P8Va8JK/img.png&quot; data-alt=&quot;가장 중요한 부분. 실제로는 C++로 썼지만 일단 BP 이미지를 가져옴&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BslRp/dJMcajofKfX/keLUcuPOAl6RQl8P8Va8JK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBslRp%2FdJMcajofKfX%2FkeLUcuPOAl6RQl8P8Va8JK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;456&quot; height=&quot;511&quot; data-origin-width=&quot;456&quot; data-origin-height=&quot;511&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;가장 중요한 부분. 실제로는 C++로 썼지만 일단 BP 이미지를 가져옴&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 드래그시 아이템 프리뷰가 보이는 부분을 UMG 자체의 Widget Drag로 사용하고 있었는데, 이 기능은 말 그대로 '위젯'을 '드래그' 하기 위한 기능이기 때문에, 당연히 위젯이 아닌걸 드래그해서 프리뷰를 보여주는데 위젯이 아닌 자리를 사용할 수 없었음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;428&quot; data-origin-height=&quot;538&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/t5EAH/dJMcabqjYBZ/dNhjFJspxAAaoPgzv4DxyK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/t5EAH/dJMcabqjYBZ/dNhjFJspxAAaoPgzv4DxyK/img.png&quot; data-alt=&quot;퀵슬롯 위젯 하이어라키. 하위 위젯 없이 혼자 다 처리함&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/t5EAH/dJMcabqjYBZ/dNhjFJspxAAaoPgzv4DxyK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Ft5EAH%2FdJMcabqjYBZ%2FdNhjFJspxAAaoPgzv4DxyK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;428&quot; height=&quot;538&quot; data-origin-width=&quot;428&quot; data-origin-height=&quot;538&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;퀵슬롯 위젯 하이어라키. 하위 위젯 없이 혼자 다 처리함&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉 실제로 위젯을 드래그하는게 아니고 내부에 담긴 '데이터'를 드래그 하는것이었기 때문에, 드래그의 주체인 퀵슬롯 위젯의 좌상단에서 시작하는 엉망진창 위젯이 되어버린 것&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 Widget Drag 기능은 포기하고, 아주 올드하게 변경함. 그냥 드래그 시작한 마우스 위치에 위젯을 생성하는 것.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;368&quot; data-origin-height=&quot;123&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/n8ROR/dJMcaaroWHP/P9zrefXC59lfaWF6ROX2kK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/n8ROR/dJMcaaroWHP/P9zrefXC59lfaWF6ROX2kK/img.png&quot; data-alt=&quot;어차피 Drag Preview를 보여줄 위젯은 있다&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/n8ROR/dJMcaaroWHP/P9zrefXC59lfaWF6ROX2kK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fn8ROR%2FdJMcaaroWHP%2FP9zrefXC59lfaWF6ROX2kK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;368&quot; height=&quot;123&quot; data-origin-width=&quot;368&quot; data-origin-height=&quot;123&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;어차피 Drag Preview를 보여줄 위젯은 있다&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정통적인(?) 방식으로 위젯을 직접 마우스 위치에서 생성하고 마우스를 따라가게 만들었더니, 의도한대로 멀쩡하게 작동함. 쉽게 해결할 수 있는걸 괜히 복잡하게 해결한 느낌이 든다...&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;653&quot; data-origin-height=&quot;316&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d2cx6g/dJMcacJw92w/kkY51HTFkYZq9RUgaCdiEK/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d2cx6g/dJMcacJw92w/kkY51HTFkYZq9RUgaCdiEK/img.gif&quot; data-alt=&quot;잘 된다&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d2cx6g/dJMcacJw92w/kkY51HTFkYZq9RUgaCdiEK/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/d2cx6g/dJMcacJw92w/kkY51HTFkYZq9RUgaCdiEK/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;653&quot; height=&quot;316&quot; data-origin-width=&quot;653&quot; data-origin-height=&quot;316&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;잘 된다&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>언리얼 7기 본캠프</category>
      <author>주철민</author>
      <guid isPermaLink="true">https://jcmtil.tistory.com/101</guid>
      <comments>https://jcmtil.tistory.com/101#entry101comment</comments>
      <pubDate>Mon, 13 Apr 2026 20:45:29 +0900</pubDate>
    </item>
    <item>
      <title>260409 TIL</title>
      <link>https://jcmtil.tistory.com/100</link>
      <description>&lt;pre id=&quot;code_1775740885640&quot; class=&quot;cpp&quot; data-ke-language=&quot;cpp&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//양궁대회 문제. 뭔 대회 규칙이 이런가 싶다마는...
//dfs를 활용해서 풀었고, 여러가지 규칙들을 세밀하게 적용하는 부분이 생각보다 많이 어려웠음
//그래도 잘 풀리긴 해서 다행
#include &amp;lt;string&amp;gt;
#include &amp;lt;vector&amp;gt;

using namespace std;

int maxdiff = 0;
vector&amp;lt;int&amp;gt; bestscore = {-1};

void DFS(int n, const vector&amp;lt;int&amp;gt;&amp;amp; info, vector&amp;lt;int&amp;gt; currentscore, int depth){
    if (depth == 11 || n == 0)
    {
        currentscore[10] += n;
        int lionscore = 0;
        int apeachscore = 0;
        
        for(int i = 0; i &amp;lt; 11; i++)
        {
            if (currentscore[i] == 0 &amp;amp;&amp;amp; info[i] == 0) continue;
            if (currentscore[i] &amp;gt; info[i]) lionscore += 10 - i;
            else apeachscore += 10 - i;
        }
        
        int diff = lionscore - apeachscore;
        
        if (diff &amp;gt; 0 &amp;amp;&amp;amp; diff &amp;gt; maxdiff)
        {
            maxdiff = diff;
            bestscore = currentscore;
        }
        else if (diff &amp;gt; 0 &amp;amp;&amp;amp; diff == maxdiff)
        {
            for(int i = 10; i &amp;gt;= 0; i--)
            {
                if (currentscore[i] &amp;gt; bestscore[i])
                {
                    bestscore = currentscore;
                    break;
                }
                else if (currentscore[i] &amp;lt; bestscore[i]) break;
            }
        }
        
        return;
    }
    
    DFS(n, info, currentscore, depth + 1);
    
    if (n &amp;gt; info[depth])
    {
        int nextn = n - (info[depth] + 1);
        currentscore[depth] = info[depth] + 1;
        DFS(nextn, info, currentscore, depth + 1);
    }
}

vector&amp;lt;int&amp;gt; solution(int n, vector&amp;lt;int&amp;gt; info) {
    vector&amp;lt;int&amp;gt; answer;
    DFS(n, info, vector&amp;lt;int&amp;gt;(11, 0), 0);
    
    answer = bestscore;
    
    return answer;
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>언리얼 7기 본캠프</category>
      <author>주철민</author>
      <guid isPermaLink="true">https://jcmtil.tistory.com/100</guid>
      <comments>https://jcmtil.tistory.com/100#entry100comment</comments>
      <pubDate>Thu, 9 Apr 2026 22:21:30 +0900</pubDate>
    </item>
  </channel>
</rss>