TIL
07.16 TIL
2026. 7. 16. 21:09

일정표

시간 할 일 비고
08:30~10:00 코드카타  
10:00~13:00 TA  
14:00~18:00 팀프로젝트  
19:00~21:00 TIL, 개인프로젝트  
21:00~22:00 운동  
22:00~23:30 개인 프로젝트  


*오늘의 코드카타*

 

문제. 옹알이

더보기

머쓱이는 태어난 지 11개월 된 조카를 돌보고 있습니다. 조카는 아직 "aya", "ye", "woo", "ma" 네 가지 발음과 네 가지 발음을 조합해서 만들 수 있는 발음밖에 하지 못하고 연속해서 같은 발음을 하는 것을 어려워합니다. 문자열 배열 babbling이 매개변수로 주어질 때, 머쓱이의 조카가 발음할 수 있는 단어의 개수를 return하도록 solution 함수를 완성해주세요

#include <string>
#include <vector>

using namespace std;

int solution(vector<string> babbling) {
    int answer = 0;
    
    for (const string& s : babbling) {
        int i = 0;
        string last = "";
        bool is_valid = true;
        
        while (i < s.length()) {
            if (i + 3 <= s.length() && s.substr(i, 3) == "aya" && last != "aya") {
                last = "aya";
                i += 3;
            }
            else if (i + 2 <= s.length() && s.substr(i, 2) == "ye" && last != "ye") {
                last = "ye";
                i += 2;
            } 
            else if (i + 3 <= s.length() && s.substr(i, 3) == "woo" && last != "woo") {
                last = "woo";
                i += 3;
            } 
            else if (i + 2 <= s.length() && s.substr(i, 2) == "ma" && last != "ma") {
                last = "ma";
                i += 2;
            }
            else {
                is_valid = false;
                break;
            }
        }
        if (is_valid && i == s.length()) {
            answer++;
        }
    }
    
    return answer;
}

*TA*

주제 : 3D 그래픽스

 

1. 기본 개념

Mesh : 3D 오브젝트의 형태를 표현하는 데이터 구조, Vertex와 Triagle의 묶음이다.

Vertex : 3D 공간의 점

Triangle : Vertex 3개로 구성된 면

 

2. Mesh란?

Mesh는 3D 오브젝트의 형태를 표현하는 데이터 구조이다. 게임에서 볼 수 있는 오브젝트들은 대부분 Mesh를 기반으로 한다.

 

3. Vertex란?

Vertex는 3D 공간의 한 점이다. 3D 공간의 위치는 X,Y,Z 좌표로 표현한다. Vertex3는 위치와 방향을 표현할 수 있는 수학 데이터이고, Vertex는 그 위치를 Mesh를 구성하는 점으로 사용한다.

struct Vector3
{
    float X;
    float Y;
    float Z;

    Vector3()
        : X(0.0f), Y(0.0f), Z(0.0f)
    {
    }

    Vector3(float x, float y, float z)
        : X(x), Y(y), Z(z)
    {
    }
};

struct Vertex
{
    Vector3 Position;

    Vertex()
        : Position()
    {
    }

    Vertex(float x, float y, float z)
        : Position(x, y, z)
    {
    }
};

 

4. Triangle?

Triangle은 세 개의 Vertex로 만들어진 면이다. Vertex 배열의 0번, 1번, 2번 Vertex를 연결한다. Vertex 값을 Triangle마다 반복해서 저장하지 않고 Index로 참조하면 같은 Vertex를 재사용할 수 있게 된다.

struct Triangle
{
    int Index0;
    int Index1;
    int Index2;

    Triangle()
        : Index0(0), Index1(0), Index2(0)
    {
    }

    Triangle(int index0, int index1, int index2)
        : Index0(index0), Index1(index1), Index2(index2)
    {
    }
};

 

두 삼각형은 같은 세 점을 사용하지만 연결 순서가 반대가 될 수 있다. 이를 표현하는 것이 Winding Order이며, 삼각형을 구성하는 Vertex를 어떤 순서로 연결했는지를 의미한다.

 

5. MeshObject

Vertex 목록과 Triangle 목록으로 이루어져 있다. Vertex 목록은 점 위치를 저장하고, Triangle 목록은 정점 인덱스로 면을 구성한다.

#include <iostream>
#include <vector>

class MeshObject
{
public:
    void AddVertex(float x, float y, float z)
    {
        Vertices.emplace_back(x, y, z);
    }

    void AddTriangle(int index0, int index1, int index2)
    {
        if (!IsValidVertexIndex(index0) ||
            !IsValidVertexIndex(index1) ||
            !IsValidVertexIndex(index2))
        {
            std::cout << "Invalid triangle index." << std::endl;
            return;
        }

        Triangles.emplace_back(index0, index1, index2);
    }

    int GetVertexCount() const
    {
        return static_cast<int>(Vertices.size());
    }

    int GetTriangleCount() const
    {
        return static_cast<int>(Triangles.size());
    }

    const std::vector<Vertex>& GetVertices() const
    {
        return Vertices;
    }

    const std::vector<Triangle>& GetTriangles() const
    {
        return Triangles;
    }

    void PrintInfo() const
    {
        std::cout << "Vertex Count: "
                  << GetVertexCount()
                  << std::endl;

        std::cout << "Triangle Count: "
                  << GetTriangleCount()
                  << std::endl;

        PrintVertices();
        PrintTriangles();
    }

private:
    bool IsValidVertexIndex(int index) const
    {
        return index >= 0 &&
               index < static_cast<int>(Vertices.size());
    }

    void PrintVertices() const
    {
        std::cout << "\n[Vertices]" << std::endl;

        for (size_t i = 0; i < Vertices.size(); ++i)
        {
            const Vector3& position = Vertices[i].Position;

            std::cout
                << "[" << i << "] "
                << "("
                << position.X << ", "
                << position.Y << ", "
                << position.Z
                << ")"
                << std::endl;
        }
    }

    void PrintTriangles() const
    {
        std::cout << "\n[Triangles]" << std::endl;

        for (size_t i = 0; i < Triangles.size(); ++i)
        {
            const Triangle& triangle = Triangles[i];

            std::cout
                << "[" << i << "] "
                << triangle.Index0 << " -> "
                << triangle.Index1 << " -> "
                << triangle.Index2
                << std::endl;
        }
    }

private:
    std::vector<Vertex> Vertices;
    std::vector<Triangle> Triangles;
};

 

6. Triangle Mesh

MeshObject CreateTriangle()
{
    MeshObject mesh;

    mesh.AddVertex(0.0f, 0.0f, 0.0f);       // 0
    mesh.AddVertex(100.0f, 0.0f, 0.0f);     // 1
    mesh.AddVertex(0.0f, 100.0f, 0.0f);     // 2

    mesh.AddTriangle(0, 1, 2);

    return mesh;
}

 

7. Quad Mesh

MeshObject CreateQuad()
{
    MeshObject mesh;

    mesh.AddVertex(0.0f, 0.0f, 0.0f);       // 0
    mesh.AddVertex(100.0f, 0.0f, 0.0f);     // 1
    mesh.AddVertex(100.0f, 100.0f, 0.0f);   // 2
    mesh.AddVertex(0.0f, 100.0f, 0.0f);     // 3

    mesh.AddTriangle(0, 1, 2);
    mesh.AddTriangle(0, 2, 3);

    return mesh;
}

 

8. Cube

Cube는 여섯 개의 사각형 면을 가진다. 면 6개. 즉, 면 하나당 2개의 삼각형이 배치되므로, 큐브는 12개의 삼각형으로 이루어진 메쉬 오브젝트이다.

Cube의 크기를 Size라고 하고, 절반 크기를 HalfSize라고 한다.

MeshObject CreateCube(float size)
{
    MeshObject cube;

    const float h = size * 0.5f;

    cube.AddVertex(-h, -h, -h); // 0
    cube.AddVertex( h, -h, -h); // 1
    cube.AddVertex( h,  h, -h); // 2
    cube.AddVertex(-h,  h, -h); // 3

    cube.AddVertex(-h, -h,  h); // 4
    cube.AddVertex( h, -h,  h); // 5
    cube.AddVertex( h,  h,  h); // 6
    cube.AddVertex(-h,  h,  h); // 7

    // 아래쪽 면
    cube.AddTriangle(0, 2, 1);
    cube.AddTriangle(0, 3, 2);

    // 위쪽 면
    cube.AddTriangle(4, 5, 6);
    cube.AddTriangle(4, 6, 7);

    // 앞쪽 면
    cube.AddTriangle(0, 1, 5);
    cube.AddTriangle(0, 5, 4);

    // 뒤쪽 면
    cube.AddTriangle(3, 7, 6);
    cube.AddTriangle(3, 6, 2);

    // 왼쪽 면
    cube.AddTriangle(0, 4, 7);
    cube.AddTriangle(0, 7, 3);

    // 오른쪽 면
    cube.AddTriangle(1, 2, 6);
    cube.AddTriangle(1, 6, 5);

    return cube;
}

int main()
{
    MeshObject cube = CreateCube(100.0f);

    cube.PrintInfo();

    return 0;
}

 

9. Position Vertex

Position -> Vertex의 위치를 결정한다.

Triangle Index를 사용해서, Vertex 세 개를 이용해 면을 구성한다.

 

Rendering Vertex의 종류를 알아보자(접은글)

핵심은 Position(Vertex의 3D 위치), UV(Texture를 읽기 위한 좌표), Normal(표면이 향하는 방향), Tangent(표면 위의 기준 방향)이다.

더보기
데이터 역할
Position Vertex의 3D 위치
UV Texture를 읽기 위한 좌표
Normal 표면이 향하는 방향
Tangent 표면 위의 기준 방향
Color Vertex 단위 색상
Bone Index Skeletal Mesh의 뼈 정보
Bone Weight 뼈의 영향력

10. Cube의 Vertex

Cube의 Vertex는 8개에서 24개가 될 수 있다. 공간상 꼭짓점은 8개이지만, 같은 위치를 여러 면에서 공유하는 것이 가능하기 때문에 면 6개 -> 면 하나당 Vertex 4개이고, Rendering Vertex 24개, Triangle 12개로 표현할 수 있다. 고유한 Position 수는 8개(꼭짓점 수)이지만, Rendering Vertex 수는 24개가 될 수 있는 것이다.

 

11. Vector2

UV에서는 X,Y 대신에 U, V라고 부르기도 한다.

struct Vector2
{
    float X;
    float Y;

    Vector2()
        : X(0.0f), Y(0.0f)
    {
    }

    Vector2(float x, float y)
        : X(x), Y(y)
    {
    }
};

 

12. MeshVertex

struct MeshVertex
{
    Vector3 Position;
    Vector2 UV;
    Vector3 Normal;
    Vector3 Tangent;

    MeshVertex()
        : Position(),
          UV(),
          Normal(0.0f, 0.0f, 1.0f),
          Tangent(1.0f, 0.0f, 0.0f)
    {
    }

    MeshVertex(
        const Vector3& position,
        const Vector2& uv,
        const Vector3& normal,
        const Vector3& tangent)
        : Position(position),
          UV(uv),
          Normal(normal),
          Tangent(tangent)
    {
    }
};

 

13. SurfaceMeshObject 구현

#include <iostream>
#include <vector>

struct Triangle
{
    int Index0;
    int Index1;
    int Index2;

    Triangle(int index0, int index1, int index2)
        : Index0(index0),
          Index1(index1),
          Index2(index2)
    {
    }
};

class SurfaceMeshObject
{
public:
    int AddVertex(const MeshVertex& vertex)
    {
        Vertices.push_back(vertex);

        return static_cast<int>(Vertices.size()) - 1;
    }

    void AddTriangle(int index0, int index1, int index2)
    {
        if (!IsValidVertexIndex(index0) ||
            !IsValidVertexIndex(index1) ||
            !IsValidVertexIndex(index2))
        {
            std::cout << "Invalid triangle index." << std::endl;
            return;
        }

        Triangles.emplace_back(index0, index1, index2);
    }

    int GetVertexCount() const
    {
        return static_cast<int>(Vertices.size());
    }

    int GetTriangleCount() const
    {
        return static_cast<int>(Triangles.size());
    }

    const std::vector<MeshVertex>& GetVertices() const
    {
        return Vertices;
    }

    const std::vector<Triangle>& GetTriangles() const
    {
        return Triangles;
    }

    void PrintInfo() const
    {
        std::cout
            << "Vertex Count: "
            << GetVertexCount()
            << std::endl;

        std::cout
            << "Triangle Count: "
            << GetTriangleCount()
            << std::endl;
    }

private:
    bool IsValidVertexIndex(int index) const
    {
        return index >= 0 &&
               index < static_cast<int>(Vertices.size());
    }

private:
    std::vector<MeshVertex> Vertices;
    std::vector<Triangle> Triangles;
};

 

14. Quad 면을 추가하기

AddQuadFace. Cube 전체에 24개의 Vertex를 직접 작성하면 코드가 길고 반복되므로, Quad 면 하나를 추가하는 함수를 만드는 것이다. 각 면은 독립적인 UV를 가지게 된다.

void AddQuadFace(
    SurfaceMeshObject& mesh,
    const Vector3& position0,
    const Vector3& position1,
    const Vector3& position2,
    const Vector3& position3,
    const Vector3& normal,
    const Vector3& tangent)
{
    const int baseIndex = mesh.GetVertexCount();

    mesh.AddVertex(MeshVertex(
        position0,
        Vector2(0.0f, 0.0f),
        normal,
        tangent));

    mesh.AddVertex(MeshVertex(
        position1,
        Vector2(1.0f, 0.0f),
        normal,
        tangent));

    mesh.AddVertex(MeshVertex(
        position2,
        Vector2(1.0f, 1.0f),
        normal,
        tangent));

    mesh.AddVertex(MeshVertex(
        position3,
        Vector2(0.0f, 1.0f),
        normal,
        tangent));

    mesh.AddTriangle(
        baseIndex,
        baseIndex + 1,
        baseIndex + 2);

    mesh.AddTriangle(
        baseIndex,
        baseIndex + 2,
        baseIndex + 3);
}

 

15. 표면 정보를 가진 Cube 생성

Triangle의 Vertex 연결 순서가 반대이면 Backface Culling으로 면이 보이지 않을 수가 있다. 이러면 Triangle Index의 두 번째와 세 번째 순서를 교체하거나, 확인용 Material에서 Two Sided를 활성화한다.

SurfaceMeshObject CreateSurfaceCube(float size)
{
    SurfaceMeshObject cube;

    const float h = size * 0.5f;

    // +X 면
    AddQuadFace(
        cube,
        Vector3( h, -h, -h),
        Vector3( h,  h, -h),
        Vector3( h,  h,  h),
        Vector3( h, -h,  h),
        Vector3(1.0f, 0.0f, 0.0f),
        Vector3(0.0f, 1.0f, 0.0f));

    // -X 면
    AddQuadFace(
        cube,
        Vector3(-h,  h, -h),
        Vector3(-h, -h, -h),
        Vector3(-h, -h,  h),
        Vector3(-h,  h,  h),
        Vector3(-1.0f, 0.0f, 0.0f),
        Vector3(0.0f, -1.0f, 0.0f));

    // +Y 면
    AddQuadFace(
        cube,
        Vector3( h, h, -h),
        Vector3(-h, h, -h),
        Vector3(-h, h,  h),
        Vector3( h, h,  h),
        Vector3(0.0f, 1.0f, 0.0f),
        Vector3(-1.0f, 0.0f, 0.0f));

    // -Y 면
    AddQuadFace(
        cube,
        Vector3(-h, -h, -h),
        Vector3( h, -h, -h),
        Vector3( h, -h,  h),
        Vector3(-h, -h,  h),
        Vector3(0.0f, -1.0f, 0.0f),
        Vector3(1.0f, 0.0f, 0.0f));

    // +Z 면
    AddQuadFace(
        cube,
        Vector3(-h, -h, h),
        Vector3( h, -h, h),
        Vector3( h,  h, h),
        Vector3(-h,  h, h),
        Vector3(0.0f, 0.0f, 1.0f),
        Vector3(1.0f, 0.0f, 0.0f));

    // -Z 면
    AddQuadFace(
        cube,
        Vector3(-h,  h, -h),
        Vector3( h,  h, -h),
        Vector3( h, -h, -h),
        Vector3(-h, -h, -h),
        Vector3(0.0f, 0.0f, -1.0f),
        Vector3(1.0f, 0.0f, 0.0f));

    return cube;
}

 

 

16. UV란?

Texture는 2D 이미지이고, Mesh는 3D 표면이다. Texture의 어느 위치를 Mesh의 어느 부분에 붙일지 알려주는 좌표가 필요하다.

Position = 3D 공간 좌표

UV = 2D Texture 좌표이다.

 

17. Quad의 UV

UV는 Vertex에 저장된다. UV는 면 하나에 저장되는 것이 아니라 일반적으로 Vertex 속성으로 저장되기 때문인데, 그래픽스 파이프라인에서는 Vertex에 저장된 UV가 Triangle 내부에서 보간된다.

 

18. Texture Sampling

Texture Sampling은 UV 좌표를 사용해 Texture의 색상이나 데이터를 읽는 과정이다. UV -> Texture 위치 선택 -> 색상 또는 데이터 읽기 -> Material 계산에 사용 / 순서로 이루어진다.

 

19. Normal

Normal은 표면이 어느 방향을 향하는지 나타내는 방향 벡터로, Cube의 각 면은 서로 다른 Normal을 가진다.

+X 면 Normal = (1, 0, 0)
-X 면 Normal = (-1, 0, 0)

+Y 면 Normal = (0, 1, 0)
-Y 면 Normal = (0, -1, 0)

+Z 면 Normal = (0, 0, 1)
-Z 면 Normal = (0, 0, -1)

 

Normal Map은 Texture에 저장된 표면 방향 정보로, 조명 계산에 사용되는 표면 방향값을 변경한다.

 

20. Tangent

Tangent는 표면 위를 따라가는 기준 방향 중 하나로, Normal은 표면 바깥 방향을 나타내고, Tangent는 표면 위의 U 방향 기준을 나타낸다. 즉, Tangent는 Normal Map의 방향 정보를 Mesh 표면에 맞게 해석하기 위한 기준 방향이다.

 

21. 그래픽스 파이프라인

그래픽스 파이프라인은 3D 공간의 Mesh 데이터를 모니터에 표시되는 2D Pixel 결과로 변환하는 과정이다.

그래픽스 파이프라인이 시작되기 전에 CPU는 화면에 그릴 데이터를 준비한다.

어떤 Mesh를 그릴 것인가? 

어디에 배치할 것인가? 

어떤 Material을 사용할 것인가? 

어떤 Texture를 사용할 것인가? 

어떤 Camera로 볼 것인가? 

어떤 Light가 존재하는가?

CPU는 이러한 정보를 정리한 뒤 GPU에 렌더링 명령을 전달한다.

 

22. Mesh Data

Mesh는 3D 오브젝트의 형태와 표면을 표현하기 위한 데이터이다.

struct MeshVertex { 
	Vector3 Position; 
	Vector2 UV; 
	Vector3 Normal; 
	Vector3 Tangent; 
};

 

23. Buffer의 종류

 

1) Vertex Buffer

GPU가 사용할 Vertex 데이터를 저장한다.

Vertex Buffer→ UV→ Tangent → Normal → Position

예를 들어 Cube를 구성하는 Vertex 데이터를 저장한다.

 

2) Index Buffer

어떤 Vertex를 연결해 Triangle을 만들 것인지 저장한다.

Triangle 0 = 0, 1, 2
Triangle 1 = 0, 2, 3

 

24. Input Assembler

Vertex Buffer와 Index Buffer를 읽어 GPU가 처리할 Vertex와 Triangle을 준비한다.

 

25. Vertex Shader

각 Vertex의 위치를 계산하는 단계. Mesh에 저장된 Position은 일반적으로 오브젝트 자신을 기준으로 하는 Local Space 좌표이다.

 

26. Primitive Assembly

Vertex Shader를 통과한 Vertex들은 다시 Triangle로 조립한다.

 

27. Clipping

화면에 그릴 필요가 없는 Triangle은 이 과정에서 제외될 수 있다. Camera가 볼 수 있는 범위를 벗어난 부분을 잘라낸다.

 

28. Backface Culling

화면에 그릴 필요가 없는 Triangle은 이 과정에서 제외될 수 있다.Camera 반대쪽을 향하고 있는 Triangle의 뒷면을 제외할 수 있다.

 

29. Rasterizer

Triangle을 화면의 Pixel 후보로 변환한다.

 

30. Vertex 데이터 보간

UV, Normal, Tangent 등의 값은 Triangle 내부에서 보간한다.

예를 들어 Triangle의 각 꼭짓점에 서로 다른 UV가 있다면, Vertex UV → Triangle 내부에서 부드럽게 보간 → 각 Pixel의 UV 생성 과정을 거친다.

 

31. Pixel Shader

Rasterizer가 만든 Pixel 후보의 색상과 표면 결과를 계산한다. OpenGL이나 Vulkan에서는 비슷한 단계를 Fragment Shader라고 부르기도 한다.

 

32. Depth Test

어떤 Pixel이 Camera에 더 가까운지 판단한다.

 

33. Blending

새로 계산한 Pixel과 기존 화면의 Pixel을 합성하는 과정이다.

 

34. Render Target

Pixel Shader와 Depth Test, Blending을 통과한 결과는 Render Target에 기록한다. Render Target은 GPU가 렌더링 결과를 저장하는 이미지 공간이다.

 

35. Post Process

렌더링된 화면에 최종적인 이미지 효과를 적용한다.

 

36. 화면 출력

모든 렌더링과 Post Process가 끝나면 최종 결과가 화면에 표시한다.

 

1) 프로그래밍 가능한 주요 단계 : Vertex Shader, Pixel Shader

2) 필요한 경우 사용할 수 있는 Shader 단계 : Hull Shader, Domain Shader, Geometry Shader, Compute Shader

3) 심화 1(CUDA): Vertex 변환, Triangle 조립, Clipping, Rasterization, Depth Test, Blending, Texture Sampling, 최종 이미지 생성

4) 심화 2(FPGA) : FPGA를 통해서 하드웨어의 새로운 규칙을 만들고 나만의 파이프라인을 가진 GPU를 만들 수 있다.

더보기
요소 변경 예시
Primitive Triangle 대신 Point, Quad, Gaussian
Rasterization Scanline, Tile, Bounding Box 방식
정밀도 Float32 대신 Float16, Fixed Point
Interpolation Perspective 보간 또는 사용자 정의
Depth 일반 Z-buffer 또는 사용자 정의 깊이 구조
Blending Alpha Blend 대신 별도 합성 공식
처리 순서 Depth Test를 Shading보다 먼저 수행
메모리 Tile Cache, Line Buffer 직접 설계
출력 HDMI, DisplayPort, Frame Buffer 연결

37. 언리얼에서는

### Forward

오브젝트를 그릴 때
표면 계산 + 조명 계산
→ 최종 색상 바로 출력

### Deferred

오브젝트를 먼저 그려
표면 정보를 GBuffer에 저장

그다음
GBuffer + Light
→ 최종 조명 계산
더보기
일반 그래픽스 파이프라인 vs. Unreal

 

일반 그래픽스 파이프라인 Unreal
Mesh Data Static Mesh, Skeletal Mesh, Procedural Mesh
Vertex Buffer Position, UV, Normal, Tangent
Index Buffer Triangle Index
Input Assembler Vertex Factory, Mesh Draw Command
Vertex Shader Material Vertex Shader, Vertex Factory Shader
Rasterizer RHI를 통해 GPU 고정 기능 사용
Pixel Shader Material Shader, Base Pass, Lighting Shader
Depth Test Depth Prepass, Scene Depth
Blending Translucency, Render State
Render Target Scene Color, GBuffer, Scene Depth
후처리 Bloom, Tone Mapping, TSR, Color Grading
화면 출력 Back Buffer와 Present

*팀 프로젝트*

 

1. 작업한 내용들

 

[1. 스태미나 기능]

 

(기능 추가 전 유의 사항)

 

1) "탈진 상태(Character.State.Exhausted)"의 실질적 동기화 및 입력 차단이 필요.

 

상태 태그 적용: 스태미나가 0이 되는 순간, ParcelPlayerStateComponent에 Character.State.Exhausted 태그를 부여함.

실제 동작 연동: * 탈진 태그가 부여되면 C++ 무브먼트 컴포넌트나 Hero 컴포넌트에서 달리기(Sprint) 상태를 강제로 해제(StopSprint)하고 속도를 원래대로 돌려놓아야 함. 또한, 탈진 태그가 유지되는 3초 동안은 플레이어가 Shift 키를 다시 누르더라도 달리기(Sprint) 상태로 진입하지 못하도록 StartSprint 입력 논리에서 Character.State.Exhausted 태그 여부를 검사(Guard)해야 함.

 

2) 멀티플레이어 환경

 

네트워크 권한: 싱글뿐만 아니라 협동 멀티플레이까지 고려하는 프로젝트이므로, 스태미나 차감, 회복, 탈진 판정은 무조건 "서버(Authority)"에서 이루어져야 함.

Replication: 스태미나 값(예: CurrentStamina, MaxStamina) 및 탈진 상태는 서버에서 연산된 후, 클라이언트로 복제(Replication)되어 UI에 반영되어야 패킷 변조 등의 해킹을 방지할 수 있음.

델리게이트 구성: 스태미나가 변경될 때마다 UI가 매 프레임 폴링(Tick)하지 않도록, 값이 바뀔 때 클라이언트 측으로 이벤트를 전송해 줄 멀티캐스트 델리게이트(FOnStaminaChangedSignature)를 설계해야 함.

 

3) 기획 수치 구체화 (수치 튜닝 편의성 제공)

 

스태미나의 최대치를 100.0f로 두고, 달리기 소모 시간 (8초): 초당 약 12.5f 소모 (100 / 8). 탈진 대기 시간 (3초): 3.0f 타이머 작동. 자연 회복 속도 (4초): 초당 25.0f 회복 (100 / 4). 차감 속도보다 회복 속도가 빠르게 기획.

 

1. StaminaComponent 작성

클라이언트에서는 틱(Tick)을 꺼서 연산 자원을 아끼고, 오직 서버에서만 스태미나의 차감/회복/탈진 타이머를 독점 관리하도록 하였음.

#pragma once

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "GameplayTagContainer.h"
#include "ParcelStaminaComponent.generated.h"

DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnStaminaChangedSignature, float, CurrentStamina, float, MaxStamina);

/**
 *
 * 담당자: JYW
 */
UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class PARCEL_KNIGHT_API UParcelStaminaComponent : public UActorComponent
{
    GENERATED_BODY()

public:
    UParcelStaminaComponent();

    virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
    virtual void BeginPlay() override;
    virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;

    // UI 델리게이트
    UPROPERTY(BlueprintAssignable, Category = "Stamina")
    FOnStaminaChangedSignature OnStaminaChanged;

    // 게터
    UFUNCTION(BlueprintPure, Category = "Stamina")
    FORCEINLINE float GetCurrentStamina() const { return CurrentStamina; }

    UFUNCTION(BlueprintPure, Category = "Stamina")
    FORCEINLINE float GetMaxStamina() const { return MaxStamina; }

private:
    UFUNCTION()
    void OnRep_CurrentStamina();

    // [Server]
    void StartExhaustion();
    void StopExhaustion();
    
    UPROPERTY(Replicated, EditDefaultsOnly, Category = "Stamina|Settings")
    float MaxStamina;

    UPROPERTY(EditDefaultsOnly, Category = "Stamina|Settings", meta = (ToolTip = "스태미나가 가득 찬 상태에서 바닥날 때까지 걸리는 시간 (초)"))
    float SprintDrainDuration;

    UPROPERTY(EditDefaultsOnly, Category = "Stamina|Settings", meta = (ToolTip = "탈진 상태가 유지되는 시간 (초)"))
    float ExhaustionDuration;

    UPROPERTY(EditDefaultsOnly, Category = "Stamina|Settings", meta = (ToolTip = "스태미나가 0에서 다시 가득 차기까지 걸리는 자연 회복 시간 (초)"))
    float RegenDuration;

    // 런타임 연산용 수치
    FORCEINLINE float GetDrainRate() const { return SprintDrainDuration > 0.f ? MaxStamina / SprintDrainDuration : 0.f; }
    FORCEINLINE float GetRegenRate() const { return RegenDuration > 0.f ? MaxStamina / RegenDuration : 0.f; }
    
    // 복제
    UPROPERTY(ReplicatedUsing = OnRep_CurrentStamina, VisibleAnywhere, Category = "Stamina")
    float CurrentStamina;

    FTimerHandle ExhaustionTimerHandle;
};
#include "Character/ParcelStaminaComponent.h"
#include "ParcelLog.h"
#include "Character/ParcelCharacter.h"
#include "Character/ParcelPlayerStateComponent.h"
#include "Character/ParcelMovementStatComponent.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "Net/UnrealNetwork.h"
#include "TimerManager.h"

DEFINE_LOG_CATEGORY(LogParcelStamina);

UParcelStaminaComponent::UParcelStaminaComponent()
{
    PrimaryComponentTick.bCanEverTick = true;
    PrimaryComponentTick.bStartWithTickEnabled = true;
    SetIsReplicatedByDefault(true);
    
    MaxStamina = 100.f;
    CurrentStamina = MaxStamina;
    SprintDrainDuration = 8.0f;
    ExhaustionDuration = 3.0f;
    RegenDuration = 4.0f;
}

void UParcelStaminaComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
    Super::GetLifetimeReplicatedProps(OutLifetimeProps);
    DOREPLIFETIME(UParcelStaminaComponent, CurrentStamina);
    DOREPLIFETIME(UParcelStaminaComponent, MaxStamina);
}

void UParcelStaminaComponent::BeginPlay()
{
    Super::BeginPlay();
    
    if (GetOwner() && !GetOwner()->HasAuthority())
    {
        PrimaryComponentTick.SetTickFunctionEnable(false);
    }
    else
    {
        CurrentStamina = MaxStamina;
    }
}

void UParcelStaminaComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
    Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
    
    if (!GetOwner() || !GetOwner()->HasAuthority()) return;

    AParcelCharacter* OwnerChar = Cast<AParcelCharacter>(GetOwner());
    if (!OwnerChar) return;

    UParcelPlayerStateComponent* StateComp = OwnerChar->GetParcelPlayerStateComponent();
    if (!StateComp) return;

    const FGameplayTag SprintTag = FGameplayTag::RequestGameplayTag(TEXT("Character.State.Sprinting"));
    const FGameplayTag ExhaustedTag = FGameplayTag::RequestGameplayTag(TEXT("Character.State.Exhausted"));

    const bool bIsSprinting = StateComp->HasStateTag(SprintTag);
    const bool bIsExhausted = StateComp->HasStateTag(ExhaustedTag);
    
    // 캐릭터가 전력질주 입력을 켰지만 실제로는 멈춰있을 땐 스태미나를 깎지 않음
    const bool bIsMoving = OwnerChar->GetVelocity().SizeSquared2D() > 10.f;

    float PreviousStamina = CurrentStamina;

    if (bIsSprinting && bIsMoving)
    {
        // 달리는 중 소모
        float DrainRate = GetDrainRate();
        CurrentStamina = FMath::Max(0.f, CurrentStamina - (DrainRate * DeltaTime));

        if (CurrentStamina <= 0.f && !bIsExhausted)
        {
            StartExhaustion();
        }
    }
    else if (!bIsExhausted && CurrentStamina < MaxStamina)
    {
        // 평상시 회복 (탈진 상태가 아니고 전력질주하지 않는 경우)
        float RegenRate = GetRegenRate();
        CurrentStamina = FMath::Min(MaxStamina, CurrentStamina + (RegenRate * DeltaTime));
    }

    // 값에 실질적인 변화가 생겼을 때만 RepNotify 브로드캐스트 (서버 로컬용)
    if (!FMath::IsNearlyEqual(PreviousStamina, CurrentStamina))
    {
        OnRep_CurrentStamina();
    }
}

void UParcelStaminaComponent::StartExhaustion()
{
    AParcelCharacter* OwnerChar = Cast<AParcelCharacter>(GetOwner());
    if (!OwnerChar) return;

    UParcelPlayerStateComponent* StateComp = OwnerChar->GetParcelPlayerStateComponent();
    if (!StateComp) return;
    
    StateComp->AddStateTag(FGameplayTag::RequestGameplayTag(TEXT("Character.State.Exhausted")));
    StateComp->RemoveStateTag(FGameplayTag::RequestGameplayTag(TEXT("Character.State.Sprinting")));
    
    if (UParcelMovementStatComponent* MovementStat = OwnerChar->GetParcelMovementStatComponent())
    {
        MovementStat->RefreshMoveSpeed();
    }
    
    GetWorld()->GetTimerManager().SetTimer(
        ExhaustionTimerHandle,
        this,
        &UParcelStaminaComponent::StopExhaustion,
        ExhaustionDuration,
        false
    );

    STAMINA_LOG(All, TEXT("[Server] %s가 스태미나 완전 소진으로 탈진 상태에 진입했습니다."), *OwnerChar->GetName());
}

void UParcelStaminaComponent::StopExhaustion()
{
    AParcelCharacter* OwnerChar = Cast<AParcelCharacter>(GetOwner());
    if (!OwnerChar) return;

    UParcelPlayerStateComponent* StateComp = OwnerChar->GetParcelPlayerStateComponent();
    if (!StateComp) return;
    
    StateComp->RemoveStateTag(FGameplayTag::RequestGameplayTag(TEXT("Character.State.Exhausted")));

    STAMINA_LOG(All, TEXT("[Server] %s의 탈진 상태가 해제되어 스태미나 회복을 개시합니다."), *OwnerChar->GetName());
}

void UParcelStaminaComponent::OnRep_CurrentStamina()
{
    if (OnStaminaChanged.IsBound())
    {
        OnStaminaChanged.Broadcast(CurrentStamina, MaxStamina);
    }
}

 

2. Character 코드 수정

전방 선언, 컴포넌트 변수, 게터 함수를 한 줄씩 등록했고, 생성자에서 컴포넌트를 조립하기 위해 코드 한줄을 추가하였음.

 

3. HeroComponent 보완

사용자가 스태미나가 없어서 탈진 상태(Exhausted)일 때는 Shift 키를 눌러도 전력질주(Sprint)를 하지 못하게 입력 가드(Guard)와 서버 보안 방어벽을 설계함.

 

 

4. HUDWidget 바인딩

	UFUNCTION(BlueprintImplementableEvent, Category = "ParcelUI")
	void K2_OnStaminaChanged(float CurrentStamina, float MaxStamina);
    
    UFUNCTION()
	void HandleOnStaminaChanged(float CurrentStamina, float MaxStamina);
    
    // 스태미나 바인딩
		if (UParcelStaminaComponent* StaminaComp = OwningPawn->FindComponentByClass<UParcelStaminaComponent>())
		{
			StaminaComp->OnStaminaChanged.RemoveDynamic(this, &UParcelHUDWidget::HandleOnStaminaChanged);
			StaminaComp->OnStaminaChanged.AddDynamic(this, &UParcelHUDWidget::HandleOnStaminaChanged);
			HandleOnStaminaChanged(StaminaComp->GetCurrentStamina(), StaminaComp->GetMaxStamina());
			bStaminaBound = true;
		}
        
        void UParcelHUDWidget::HandleOnStaminaChanged(float CurrentStamina, float MaxStamina)
{
	K2_OnStaminaChanged(CurrentStamina, MaxStamina);
}

 

5. WBP 작업

탈진상태일 때 테두리가 빨갛게 빛나는 효과를 연출하기 위해 Wrap With으로 스태미나 게이지를 둘러싼 Border를 덮어준다.

 

[2. (인게임)메인메뉴, 설정, 재시작, 게임종료]

1. InGameESCMenuWidget 생성 : 화면에 켜질 때 로컬 플레이어의 입력만 차단하고 마우스 커서를 보여주며, 방장(서버)에게만 '재시작' 권한을 부여하도록 설계하였음. 버튼으로는 계속하기, 재시작, 옵션, 종료 버튼이 있고, 동시에 맵 이름, 개인 점수, 접속해있는 플레이어 리스트를 확인할 수 있는 인게임 메뉴를 설계함.

#pragma once

#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "ParcelInGameESCMenuWidget.generated.h"

class UButton;
class UTextBlock;
class UVerticalBox;

/**
 * 인게임 ESC(일시정지) 메뉴의 전체 제어 및 멀티플레이어 바인딩을 담당하는 컨트롤러
 */
UCLASS()
class PARCEL_KNIGHT_API UParcelInGameESCMenuWidget : public UUserWidget
{
	GENERATED_BODY()
	
protected:
	virtual void NativeConstruct() override;

	// UI Bindings
	UPROPERTY(meta = (BindWidget))
	TObjectPtr<UButton> Btn_Resume;

	UPROPERTY(meta = (BindWidget))
	TObjectPtr<UButton> Btn_Restart;

	UPROPERTY(meta = (BindWidget))
	TObjectPtr<UButton> Btn_Options;

	UPROPERTY(meta = (BindWidget))
	TObjectPtr<UButton> Btn_Exit;

	UPROPERTY(meta = (BindWidget))
	TObjectPtr<UTextBlock> Txt_MapName;

	UPROPERTY(meta = (BindWidget))
	TObjectPtr<UTextBlock> Txt_PersonalScore;

	UPROPERTY(meta = (BindWidget))
	TObjectPtr<UVerticalBox> VB_PlayerList;

	// Handlers
	UFUNCTION()
	void HandleResumeClicked();

	UFUNCTION()
	void HandleRestartClicked();

	UFUNCTION()
	void HandleOptionsClicked();

	UFUNCTION()
	void HandleExitClicked();

public:
	UFUNCTION(BlueprintImplementableEvent, BlueprintCallable, Category = "UI", meta = (DisplayName = "OnMenuOpenStarted"))
	void K2_OnMenuOpenStarted();
    
	UFUNCTION(BlueprintImplementableEvent, BlueprintCallable, Category = "UI", meta = (DisplayName = "OnMenuCloseStarted"))
	void K2_OnMenuCloseStarted();
    
	UFUNCTION(BlueprintImplementableEvent, Category = "UI", meta = (DisplayName = "OnTeardownStarted"))
	void K2_OnTeardownStarted();
	
	UFUNCTION(BlueprintCallable, Category = "UI")
	void SetupMenu();
	
	UFUNCTION(BlueprintCallable, Category = "UI")
	void CompleteTeardown();
	
	void RefreshMenuData();

private:
	void UpdateMapName();
	void UpdatePersonalScore();
	void UpdatePlayerList();
};
#include "UI/ParcelInGameESCMenuWidget.h"
#include "Components/Button.h"
#include "Components/TextBlock.h"
#include "Components/VerticalBox.h"
#include "GameFramework/GameStateBase.h"
#include "GameFramework/PlayerState.h"
#include "Kismet/GameplayStatics.h"
#include "Core/ParcelPlayerState.h"

void UParcelInGameESCMenuWidget::NativeConstruct()
{
	Super::NativeConstruct();
	
	if (Btn_Resume)
		Btn_Resume->OnClicked.AddDynamic(this, &UParcelInGameESCMenuWidget::HandleResumeClicked);
    
	if (Btn_Restart)
		Btn_Restart->OnClicked.AddDynamic(this, &UParcelInGameESCMenuWidget::HandleRestartClicked);
    
	if (Btn_Options)
		Btn_Options->OnClicked.AddDynamic(this, &UParcelInGameESCMenuWidget::HandleOptionsClicked);
    
	if (Btn_Exit)
		Btn_Exit->OnClicked.AddDynamic(this, &UParcelInGameESCMenuWidget::HandleExitClicked);
}

void UParcelInGameESCMenuWidget::SetupMenu()
{
	APlayerController* PC = GetOwningPlayer();
	if (!PC) return;

	FInputModeGameAndUI InputMode;
	InputMode.SetWidgetToFocus(TakeWidget());
	InputMode.SetLockMouseToViewportBehavior(EMouseLockMode::DoNotLock);
    
	PC->SetInputMode(InputMode);
	PC->bShowMouseCursor = true;

	if (Btn_Restart)
	{
		if (PC->HasAuthority()) Btn_Restart->SetIsEnabled(true);
		else Btn_Restart->SetIsEnabled(false);
	}

	RefreshMenuData();
	
	K2_OnMenuOpenStarted();
}

void UParcelInGameESCMenuWidget::CompleteTeardown()
{
	APlayerController* PC = GetOwningPlayer();
	if (PC)
	{
		FInputModeGameOnly InputMode;
		PC->SetInputMode(InputMode);
		PC->bShowMouseCursor = false;
	}

	RemoveFromParent();
}

void UParcelInGameESCMenuWidget::RefreshMenuData()
{
	UpdateMapName();
	UpdatePersonalScore();
	UpdatePlayerList();
}

void UParcelInGameESCMenuWidget::UpdateMapName()
{
	if (!Txt_MapName) return;
	FString MapName = GetWorld()->GetMapName();
	MapName.RemoveFromStart(GetWorld()->StreamingLevelsPrefix);
	Txt_MapName->SetText(FText::FromString(MapName));
}

void UParcelInGameESCMenuWidget::UpdatePersonalScore()
{
	if (!Txt_PersonalScore) return;
	AParcelPlayerState* PS = Cast<AParcelPlayerState>(GetOwningPlayerState());
	if (PS)
	{
		int32 Score = PS->GetPersonalScore();
		Txt_PersonalScore->SetText(FText::Format(FText::FromString(TEXT("내 점수: {0}점")), FText::AsNumber(Score)));
	}
	else Txt_PersonalScore->SetText(FText::FromString(TEXT("내 점수: 0점")));
}

void UParcelInGameESCMenuWidget::UpdatePlayerList()
{
	if (!VB_PlayerList) return;
	VB_PlayerList->ClearChildren();

	AGameStateBase* GS = GetWorld()->GetGameState();
	if (!GS) return;
    
	for (APlayerState* PS : GS->PlayerArray)
	{
		if (PS)
		{
			UTextBlock* PlayerNameText = NewObject<UTextBlock>(this);
			if (PlayerNameText)
			{
				PlayerNameText->SetText(FText::FromString(PS->GetPlayerName()));
				FSlateFontInfo FontInfo = PlayerNameText->GetFont();
				FontInfo.Size = 16.f;
				PlayerNameText->SetFont(FontInfo);
				VB_PlayerList->AddChild(PlayerNameText);
			}
		}
	}
}

void UParcelInGameESCMenuWidget::HandleResumeClicked()
{
	K2_OnMenuCloseStarted();
}

void UParcelInGameESCMenuWidget::HandleRestartClicked()
{
	APlayerController* PC = GetOwningPlayer();
	if (PC && PC->HasAuthority())
	{
		FString CurrentMapName = GetWorld()->GetMapName();
		CurrentMapName.RemoveFromStart(GetWorld()->StreamingLevelsPrefix);
		GetWorld()->ServerTravel(CurrentMapName + "?listen");
	}
}

void UParcelInGameESCMenuWidget::HandleOptionsClicked()
{
}

void UParcelInGameESCMenuWidget::HandleExitClicked()
{
	APlayerController* PC = GetOwningPlayer();
	if (!PC) return;

	if (PC->HasAuthority()) UGameplayStatics::OpenLevel(GetWorld(), TEXT("MainMenu"));
	else PC->ClientTravel(TEXT("/Game/Maps/MainMenu"), TRAVEL_Absolute);
}

 

2. OptionsWidget : 옵션 메뉴를 선택하면 출력되는 옵션UI 화면을 신설. 사운드 볼륨, 마우스 감도 슬라이더, 그리고 UGameUserSettings 기반의 화면 모드 및 해상도 전환 기능으로 압축하여 설계하였음. FOV의 경우 1인칭으로 변경이 완벽하게 완료되고 나면 추가하기로 결정.

#pragma once

#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "ParcelOptionsWidget.generated.h"

class USlider;
class UComboBoxString;
class UButton;

/**
 * UParcelOptionsWidget
 * 볼륨, 마우스 감도 및 엔진 그래픽 세팅(해상도, 화면모드)을 조작하고 보관하는 위젯
 */
UCLASS()
class PARCEL_KNIGHT_API UParcelOptionsWidget : public UUserWidget
{
	GENERATED_BODY()

protected:
	virtual void NativeConstruct() override;

	// UI Bindings
	UPROPERTY(meta = (BindWidget))
	TObjectPtr<USlider> Slider_Volume;

	UPROPERTY(meta = (BindWidget))
	TObjectPtr<USlider> Slider_Sensitivity;

	UPROPERTY(meta = (BindWidget))
	TObjectPtr<UComboBoxString> Combo_ScreenMode;

	UPROPERTY(meta = (BindWidget))
	TObjectPtr<UComboBoxString> Combo_Resolution;

	UPROPERTY(meta = (BindWidget))
	TObjectPtr<UButton> Btn_Apply;

	UPROPERTY(meta = (BindWidget))
	TObjectPtr<UButton> Btn_Back;

	// Handlers
	UFUNCTION()
	void HandleVolumeChanged(float Value);

	UFUNCTION()
	void HandleSensitivityChanged(float Value);

	UFUNCTION()
	void HandleApplyClicked();

	UFUNCTION()
	void HandleBackClicked();

private:
	void InitializeSettings();
	void PopulateScreenModeOptions();
	void PopulateResolutionOptions();
};
#include "UI/ParcelOptionsWidget.h"

#include "Character/ParcelHeroComponent.h"
#include "Components/Slider.h"
#include "Components/ComboBoxString.h"
#include "Components/Button.h"
#include "GameFramework/GameUserSettings.h"

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

    if (Slider_Volume)
        Slider_Volume->OnValueChanged.AddDynamic(this, &UParcelOptionsWidget::HandleVolumeChanged);
    
    if (Slider_Sensitivity)
        Slider_Sensitivity->OnValueChanged.AddDynamic(this, &UParcelOptionsWidget::HandleSensitivityChanged);
    
    if (Btn_Apply)
        Btn_Apply->OnClicked.AddDynamic(this, &UParcelOptionsWidget::HandleApplyClicked);
    
    if (Btn_Back)
        Btn_Back->OnClicked.AddDynamic(this, &UParcelOptionsWidget::HandleBackClicked);

    InitializeSettings();
}

void UParcelOptionsWidget::InitializeSettings()
{
    if (Slider_Volume)
        Slider_Volume->SetValue(0.8f);
    
    if (Slider_Sensitivity)
    {
        APawn* OwningPawn = GetOwningPlayerPawn();
        if (OwningPawn)
        {
            UParcelHeroComponent* HeroComp = OwningPawn->FindComponentByClass<UParcelHeroComponent>();
            if (HeroComp)
            {
                Slider_Sensitivity->SetValue(HeroComp->GetMouseSensitivity());
            }
        }
        else
        {
            Slider_Sensitivity->SetValue(1.0f);
        }
    }

    PopulateScreenModeOptions();
    PopulateResolutionOptions();
}

void UParcelOptionsWidget::PopulateScreenModeOptions()
{
    if (!Combo_ScreenMode) return;

    Combo_ScreenMode->ClearOptions();
    Combo_ScreenMode->AddOption(TEXT("전체 화면"));
    Combo_ScreenMode->AddOption(TEXT("전체 창 모드"));
    Combo_ScreenMode->AddOption(TEXT("창 모드"));
    
    UGameUserSettings* Settings = UGameUserSettings::GetGameUserSettings();
    if (Settings)
    {
        EWindowMode::Type CurrentMode = Settings->GetFullscreenMode();
        switch (CurrentMode)
        {
        case EWindowMode::Fullscreen:
            Combo_ScreenMode->SetSelectedOption(TEXT("전체 화면"));
            break;
        case EWindowMode::WindowedFullscreen:
            Combo_ScreenMode->SetSelectedOption(TEXT("전체 창 모드"));
            break;
        case EWindowMode::Windowed:
            Combo_ScreenMode->SetSelectedOption(TEXT("창 모드"));
            break;
        }
    }
}

void UParcelOptionsWidget::PopulateResolutionOptions()
{
    if (!Combo_Resolution) return;

    Combo_Resolution->ClearOptions();
    
    TArray<FString> ResList = {
        TEXT("1920x1080"),
        TEXT("1600x900"),
        TEXT("1280x720"),
        TEXT("2560x1440")
    };

    for (const FString& Res : ResList)
    {
        Combo_Resolution->AddOption(Res);
    }

    UGameUserSettings* Settings = UGameUserSettings::GetGameUserSettings();
    if (Settings)
    {
        FIntPoint CurrentRes = Settings->GetScreenResolution();
        FString ResString = FString::Printf(TEXT("%dx%d"), CurrentRes.X, CurrentRes.Y);

        if (ResList.Contains(ResString))
        {
            Combo_Resolution->SetSelectedOption(ResString);
        }
        else
        {
            Combo_Resolution->SetSelectedOption(TEXT("1920x1080")); // Fallback 기본값
        }
    }
}

void UParcelOptionsWidget::HandleVolumeChanged(float Value)
{
    // TODO: SoundMix 및 SoundClass에 연동하여 마스터 볼륨을 조절하는 로직 추가
}

void UParcelOptionsWidget::HandleSensitivityChanged(float Value)
{
    APawn* OwningPawn = GetOwningPlayerPawn();
    if (OwningPawn)
    {
        UParcelHeroComponent* HeroComp = OwningPawn->FindComponentByClass<UParcelHeroComponent>();
        if (HeroComp)
        {
            HeroComp->SetMouseSensitivity(Value);
        }
    }
}

void UParcelOptionsWidget::HandleApplyClicked()
{
    UGameUserSettings* Settings = UGameUserSettings::GetGameUserSettings();
    if (!Settings) return;

    // 1. 화면 모드 적용
    if (Combo_ScreenMode)
    {
        FString SelectedMode = Combo_ScreenMode->GetSelectedOption();
        if (SelectedMode == TEXT("전체 화면"))
            Settings->SetFullscreenMode(EWindowMode::Fullscreen);
        else if (SelectedMode == TEXT("전체 창 모드"))
            Settings->SetFullscreenMode(EWindowMode::WindowedFullscreen);
        else if (SelectedMode == TEXT("창 모드"))
            Settings->SetFullscreenMode(EWindowMode::Windowed);
    }

    // 2. 해상도 적용
    if (Combo_Resolution)
    {
        FString SelectedRes = Combo_Resolution->GetSelectedOption();
        FString LeftStr, RightStr;
        if (SelectedRes.Split(TEXT("x"), &LeftStr, &RightStr))
        {
            int32 Width = FCString::Atoi(*LeftStr);
            int32 Height = FCString::Atoi(*RightStr);
            Settings->SetScreenResolution(FIntPoint(Width, Height));
        }
    }
    
    Settings->ApplySettings(false);
}

void UParcelOptionsWidget::HandleBackClicked()
{
    RemoveFromParent();
}

3. HeroComponent에 ESC 버튼 할당하고, IA_InGameMenu, IMC_Character에 바인딩

열심히 디자인했다. 작성된 코드와 잘 바인딩 되도록 변수 설정과 이름을 정확하게 설정했다.

#pragma once

#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "InputActionValue.h"
#include "ParcelHeroComponent.generated.h"

class USpringArmComponent;
class UCameraComponent;
class UInputMappingContext;
class UInputAction;
class UParcelInGameESCMenuWidget;

// [UI] 던지기 충전 상태 델리게이트
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnThrowChargeChangedSignature, bool, bIsCharging, float, ChargeRatio);

UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class PARCEL_KNIGHT_API UParcelHeroComponent : public UActorComponent
{
	GENERATED_BODY()
	
public:	
	UParcelHeroComponent();
	virtual void BeginPlay() override;
	
	virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
	
	// 캐릭터의 입력을 바인딩 (SetupPlayerInputComponent 시점에 호출)
	void InitializePlayerInput(UInputComponent* PlayerInputComponent);
	void AddInputMappingContext();
	
	void ResetCameraAttachment();
	
	void EnterRagdollCameraMode();
	void ExitRagdollCameraMode();
	
	// [UI] HUD 위젯 바인딩
	UPROPERTY(BlueprintAssignable, Category = "Carry|Throw")
	FOnThrowChargeChangedSignature OnThrowChargeChanged;

	// [UI] 현재 로컬 차징 상태 게터 함수들
	UFUNCTION(BlueprintPure, Category = "Carry|Throw")
	FORCEINLINE bool IsChargingThrow() const { return bIsChargingThrow; }
	
	UFUNCTION(BlueprintPure, Category = "Carry|Throw")
	FORCEINLINE float GetThrowChargeRatio() const { return MaxThrowChargeTime > 0.f ? FMath::Clamp(CurrentThrowChargeTime / MaxThrowChargeTime, 0.f, 1.f) : 0.f; }
	
	// [UI] 마우스 감도
	UFUNCTION(BlueprintCallable, Category = "Input")
	void SetMouseSensitivity(float NewSensitivity) { MouseSensitivity = NewSensitivity; }

	UFUNCTION(BlueprintPure, Category = "Input")
	float GetMouseSensitivity() const { return MouseSensitivity; }
	
	
protected:
	// 카메라 컴포넌트 (인게임에 시점 변경이 필요하다면 이것도 분리 가능)
	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
	TObjectPtr<USpringArmComponent> SpringArm;

	UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Camera")
	TObjectPtr<UCameraComponent> FollowCamera;
	
	// EnhanceInput (인게임에서 조작 변경이 필요하다면 이것도 분리 가능)
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
	TObjectPtr<UInputMappingContext> InputMappingContext;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
	TObjectPtr<UInputAction> MoveAction;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
	TObjectPtr<UInputAction> LookAction;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
	TObjectPtr<UInputAction> JumpAction;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
	TObjectPtr<UInputAction> SprintAction;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
	TObjectPtr<UInputAction> RagdollAction;
	
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
	TObjectPtr<UInputAction> InteractAction;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
	TObjectPtr<UInputAction> ThrowAction;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Carry|Throw")
	float MinThrowForce = 800.f;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Carry|Throw")
	float MaxThrowForce = 2200.f;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Carry|Throw")
	float MaxThrowChargeTime = 1.5f;

	bool bIsChargingThrow = false;
	float CurrentThrowChargeTime = 0.f;
	
	void Move(const FInputActionValue& Value);
	void Look(const FInputActionValue& Value);
	void StartJump(const FInputActionValue& Value);
	void StopJump(const FInputActionValue& Value);
	void StartSprint(const FInputActionValue& Value);
	void StopSprint(const FInputActionValue& Value);
	void TestRagdoll(const FInputActionValue& Value);
	void Interact(const FInputActionValue& Value);
	void StartThrow(const FInputActionValue& Value);
	void ReleaseThrow(const FInputActionValue& Value);
	
	// Ragdoll 카메라 세팅 에디터 노출
	UPROPERTY(EditAnywhere, Category = "Camera|Ragdoll")
	float RagdollCameraHeightOffset = 20.f;

	UPROPERTY(EditAnywhere, Category = "Camera|Ragdoll")
	float RagdollCameraBackOffset = 90.f;
	
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Camera|Collision")
	float CameraCollisionProbeSize = 18.f;
	
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Input", meta = (AllowPrivateAccess = "true"))
	float MouseSensitivity = 1.0f;
	
	// [UI] InGame ESC 버튼
	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
	TObjectPtr<UInputAction> InGameMenuAction;

	UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "UI")
	TSubclassOf<UParcelInGameESCMenuWidget> ESCMenuClass;

	UPROPERTY(Transient)
	TObjectPtr<UParcelInGameESCMenuWidget> ESCMenuRef;
	
	void ToggleInGameMenu();
	
private:
	// 달리기 기능을 위한 Server RPC
	UFUNCTION(Server, Reliable)
	void ServerSetSprinting(bool bNewIsSprinting);

	void ApplySprintSpeed(bool bNewIsSprinting);
	bool CanProcessLocalInput() const;
	
	UFUNCTION(Server, Reliable)
	void ServerSetJumping(bool bNewIsJumping);

	void ApplyJumpTag(bool bNewIsJumping);
};
#include "Character/ParcelHeroComponent.h"
#include "ParcelLog.h"
#include "GameFramework/Character.h"
#include "GameFramework/PlayerController.h"
#include "GameFramework/SpringArmComponent.h"
#include "Camera/CameraComponent.h"
#include "EnhancedInputSubsystems.h"
#include "EnhancedInputComponent.h"
#include "Character/ParcelCharacter.h"
#include "Character/RagdollComponent.h"
#include "Character/ParcelInteractionComponent.h"
#include "Character/ParcelMovementStatComponent.h"
#include "Character/CharacterCarryComponent.h"
#include "Character/ParcelPlayerStateComponent.h"
#include "UI/ParcelInGameESCMenuWidget.h"
#include "Components/DFStatusEffectComponent.h"

DEFINE_LOG_CATEGORY(LogHeroComp);

UParcelHeroComponent::UParcelHeroComponent()
{
    PrimaryComponentTick.bCanEverTick = true;
    PrimaryComponentTick.bStartWithTickEnabled = false;
    
    // [Server] : 컴포넌트에서 Server RPC 가동
    SetIsReplicatedByDefault(true);
    
    MouseSensitivity = 1.0f;

    // 카메라
    SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArm"));
    SpringArm->TargetArmLength = 350.f;
    SpringArm->bDoCollisionTest = true;
    SpringArm->ProbeChannel = ECC_Camera;
    SpringArm->ProbeSize = CameraCollisionProbeSize;
    SpringArm->bUsePawnControlRotation = true;
    SpringArm->bEnableCameraLag = true;
    SpringArm->CameraLagSpeed = 10.f;

    FollowCamera = CreateDefaultSubobject<UCameraComponent>(TEXT("FollowCamera"));
    FollowCamera->bUsePawnControlRotation = false;
}

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

    // 부모 캐릭터 루트에 카메라 부착
    if (ACharacter* Character = Cast<ACharacter>(GetOwner()))
    {
       SpringArm->AttachToComponent(Character->GetRootComponent(), FAttachmentTransformRules::SnapToTargetNotIncludingScale);
       FollowCamera->AttachToComponent(SpringArm, FAttachmentTransformRules::SnapToTargetNotIncludingScale, USpringArmComponent::SocketName);
    
       HEROCOMP_LOG(Log, TEXT("[%s] 캐릭터에 카메라 컴포넌트 부착 완료."), *Character->GetName());
    }

    AddInputMappingContext();
}

void UParcelHeroComponent::ResetCameraAttachment()
{
    if (ACharacter* Character = Cast<ACharacter>(GetOwner()))
    {
       if (SpringArm)
       {
          SpringArm->AttachToComponent(Character->GetRootComponent(), FAttachmentTransformRules::SnapToTargetNotIncludingScale);
          SpringArm->SetRelativeLocation(FVector::ZeroVector);
       }
    }
}

void UParcelHeroComponent::TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
    Super::TickComponent(DeltaTime, TickType, ThisTickFunction);

    ACharacter* Character = Cast<ACharacter>(GetOwner());
    if (!Character) return;

    if (Character->IsLocallyControlled() && bIsChargingThrow)
    {
        // [UI] 방어 코드 : 던지는 도중 맞거나 래그돌 등 상자 놓친 경우 예외 처리
        UCharacterCarryComponent* CarryComp = Character->FindComponentByClass<UCharacterCarryComponent>();
        if (!CarryComp || !CarryComp->IsCarrying())
        {
            HEROCOMP_LOG(Warning, TEXT("차징 연출을 강제 취소합니다."));
            bIsChargingThrow = false;
            CurrentThrowChargeTime = 0.f;
            
            OnThrowChargeChanged.Broadcast(false, 0.0f);
            
            if (AParcelCharacter* ParcelChar = Cast<AParcelCharacter>(Character))
            {
                if (UParcelPlayerStateComponent* StateComp = ParcelChar->GetParcelPlayerStateComponent())
                {
                    StateComp->RemoveStateTag(FGameplayTag::RequestGameplayTag(TEXT("Character.Action.Throwing")));
                }
            }
            
            URagdollComponent* RagdollComp = Character->FindComponentByClass<URagdollComponent>();
            bool bNeedsTick = RagdollComp && RagdollComp->IsRagdoll();
            if (!bNeedsTick)
            {
                PrimaryComponentTick.SetTickFunctionEnable(false);
            }
            return;
        }
        
        CurrentThrowChargeTime += DeltaTime;
        if (CurrentThrowChargeTime > MaxThrowChargeTime)
        {
            CurrentThrowChargeTime = MaxThrowChargeTime;
        }
        
        // [UI] 프레임마다 변경되는 ChargeRatio 전달
        float ChargeRatio = MaxThrowChargeTime > 0.f ? FMath::Clamp(CurrentThrowChargeTime / MaxThrowChargeTime, 0.f, 1.f) : 0.f;
        OnThrowChargeChanged.Broadcast(true, ChargeRatio);
        
    }

    URagdollComponent* RagdollComp = Character->FindComponentByClass<URagdollComponent>();

    // 래그돌 상태가 활성화 되었을 때만 카메라 연산 가동함(카메라 보정)
    if (Character->IsLocallyControlled() && RagdollComp && RagdollComp->IsRagdoll() && Character->GetMesh() && SpringArm)
    {
        const FVector HeadLocation = Character->GetMesh()->GetSocketLocation(TEXT("head"));
        const FRotator ViewRotation = Character->GetController() ? Character->GetController()->GetControlRotation() : Character->GetActorRotation();
        const FVector CameraBackDirection = -FRotationMatrix(ViewRotation).GetUnitAxis(EAxis::X);
        const FVector TargetLocation = HeadLocation + FVector::UpVector * RagdollCameraHeightOffset + CameraBackDirection * RagdollCameraBackOffset;

        SpringArm->SetWorldLocation(TargetLocation);
    }
}

void UParcelHeroComponent::InitializePlayerInput(UInputComponent* PlayerInputComponent)
{
    AddInputMappingContext();

    if (!CanProcessLocalInput()) return;

    // IA 바인딩
    UEnhancedInputComponent* EnhancedInputComponent = Cast<UEnhancedInputComponent>(PlayerInputComponent);
    if (!EnhancedInputComponent) return;
    
    if (MoveAction) EnhancedInputComponent->BindAction(MoveAction, ETriggerEvent::Triggered, this, &UParcelHeroComponent::Move);
    
    if (LookAction) EnhancedInputComponent->BindAction(LookAction, ETriggerEvent::Triggered, this, &UParcelHeroComponent::Look);
    
    if (JumpAction)
    {
       EnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Started, this, &UParcelHeroComponent::StartJump);
       EnhancedInputComponent->BindAction(JumpAction, ETriggerEvent::Completed, this, &UParcelHeroComponent::StopJump);
    }
    
    if (SprintAction)
    {
       EnhancedInputComponent->BindAction(SprintAction, ETriggerEvent::Started, this, &UParcelHeroComponent::StartSprint);
       EnhancedInputComponent->BindAction(SprintAction, ETriggerEvent::Completed, this, &UParcelHeroComponent::StopSprint);
       EnhancedInputComponent->BindAction(SprintAction, ETriggerEvent::Canceled, this, &UParcelHeroComponent::StopSprint);
    }
    
    if (RagdollAction) EnhancedInputComponent->BindAction(RagdollAction, ETriggerEvent::Started, this, &UParcelHeroComponent::TestRagdoll);
    
    if (InteractAction) 
    {
       EnhancedInputComponent->BindAction(InteractAction, ETriggerEvent::Started, this, &UParcelHeroComponent::Interact);
    }
    
    if (ThrowAction)
    {
       EnhancedInputComponent->BindAction(ThrowAction, ETriggerEvent::Started, this, &UParcelHeroComponent::StartThrow);
       EnhancedInputComponent->BindAction(ThrowAction, ETriggerEvent::Completed, this, &UParcelHeroComponent::ReleaseThrow);
    }
    
    if (InGameMenuAction)
    {
        EnhancedInputComponent->BindAction(InGameMenuAction, ETriggerEvent::Started, this, &UParcelHeroComponent::ToggleInGameMenu);
    }
    
    HEROCOMP_LOG(Log, TEXT("Enhanced Input 바인딩 완료."));
}

void UParcelHeroComponent::Move(const FInputActionValue& Value)
{
    ACharacter* Character = Cast<ACharacter>(GetOwner());
    if (!CanProcessLocalInput() || !Character || !Character->GetController()) return;

    // [Add] 방어 코드 : 래그돌 상태에서는 입력 처리 불가
    URagdollComponent* RagdollComp = Character->FindComponentByClass<URagdollComponent>();
    if (RagdollComp && RagdollComp->IsRagdoll()) return;
    
	// 반전 함정
	FVector2D MoveValue = Value.Get<FVector2D>();
	if (const UDFStatusEffectComponent* StatusEffectComponent = Character->FindComponentByClass<UDFStatusEffectComponent>())
	{
		if (StatusEffectComponent->IsInputInverted())
		{
			MoveValue *= -1.0f;
		}
	}

    const FRotator ControlRotation = Character->GetController()->GetControlRotation();
    const FRotator YawRotation(0.f, ControlRotation.Yaw, 0.f);

    const FVector ForwardDirection = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::X);
    const FVector RightDirection = FRotationMatrix(YawRotation).GetUnitAxis(EAxis::Y);

    Character->AddMovementInput(ForwardDirection, MoveValue.Y);
    Character->AddMovementInput(RightDirection, MoveValue.X);
}

void UParcelHeroComponent::Look(const FInputActionValue& Value)
{
    ACharacter* Character = Cast<ACharacter>(GetOwner());
    if (!CanProcessLocalInput() || !Character) return;

    const FVector2D LookValue = Value.Get<FVector2D>() * MouseSensitivity;
    
    Character->AddControllerYawInput(LookValue.X);
    Character->AddControllerPitchInput(LookValue.Y);
}

void UParcelHeroComponent::StartJump(const FInputActionValue& Value)
{
    ACharacter* Character = Cast<ACharacter>(GetOwner());
    if (!CanProcessLocalInput() || !Character) return;

    // 던지기(Throwing) 액션 중에는 물리적인 점프 발동을 차단
    if (AParcelCharacter* ParcelChar = Cast<AParcelCharacter>(Character))
    {
        if (UParcelPlayerStateComponent* StateComp = ParcelChar->GetParcelPlayerStateComponent())
        {
            // 점프 시 렉 방어
            FGameplayTag ThrowingAction = FGameplayTag::RequestGameplayTag(TEXT("Character.Action.Throwing"), false);
            FGameplayTag ThrowingState = FGameplayTag::RequestGameplayTag(TEXT("Character.State.Throwing"), false);
            
            // State와 Action 두 태그 명칭 모두 유연하게 검증하도록 방어 코드 작성
            if (StateComp->HasStateTag(FGameplayTag::RequestGameplayTag(TEXT("Character.Action.Throwing"))) ||
                StateComp->HasStateTag(FGameplayTag::RequestGameplayTag(TEXT("Character.State.Throwing")))) 
            {
                return;
            }
        }
    }

    // 물리적인 점프 기능 수행
    Character->Jump();

    // 점프 액션 태그 로컬 적용 및 서버 동기화 요청
    ApplyJumpTag(true);
    if (!Character->HasAuthority())
    {
        ServerSetJumping(true);
    }
}

void UParcelHeroComponent::StopJump(const FInputActionValue& Value)
{
    ACharacter* Character = Cast<ACharacter>(GetOwner());
    if (!CanProcessLocalInput() || !Character) return;

    // 물리적인 점프 입력 중단
    Character->StopJumping();

    // 점프 액션 태그 로컬 해제 및 서버 동기화 요청
    ApplyJumpTag(false);
    if (!Character->HasAuthority())
    {
        ServerSetJumping(false);
    }
}

void UParcelHeroComponent::StartSprint(const FInputActionValue& Value)
{
    ACharacter* Character = Cast<ACharacter>(GetOwner());
    if (!Character) return;

    URagdollComponent* RagdollComp = Character->FindComponentByClass<URagdollComponent>();
    if (!CanProcessLocalInput() || (RagdollComp && RagdollComp->IsRagdoll())) return;
    
    if (AParcelCharacter* ParcelChar = Cast<AParcelCharacter>(Character))
    {
        if (UParcelPlayerStateComponent* StateComp = ParcelChar->GetParcelPlayerStateComponent())
        {
            if (StateComp->HasStateTag(FGameplayTag::RequestGameplayTag(TEXT("Character.State.Exhausted"))))
            {
                HEROCOMP_LOG(Warning, TEXT("탈진 상태(Exhausted). 스프린트 불가."));
                return;
            }
        }
    }

    ApplySprintSpeed(true);
    if (!Character->HasAuthority()) ServerSetSprinting(true);
}

void UParcelHeroComponent::StopSprint(const FInputActionValue& Value)
{
    ACharacter* Character = Cast<ACharacter>(GetOwner());
    if (!CanProcessLocalInput() || !Character) return;

    ApplySprintSpeed(false);
    if (!Character->HasAuthority()) ServerSetSprinting(false);
}

void UParcelHeroComponent::TestRagdoll(const FInputActionValue& Value)
{
    ACharacter* Character = Cast<ACharacter>(GetOwner());
    if (!CanProcessLocalInput() || !Character) return;

    URagdollComponent* RagdollComp = Character->FindComponentByClass<URagdollComponent>();
    if (!RagdollComp) return;

    const bool bWasRagdoll = RagdollComp->IsRagdoll();
	if (bWasRagdoll && !RagdollComp->IsRagdollCloseToGround()) 
	{
		HEROCOMP_LOG(Warning, TEXT("래그돌 해제 실패 : 현재 공중에 떠 있는 상태입니다. (지면과 너무 멂)"));
		return;
	}

    RagdollComp->ToggleRagdoll();

    // 래그돌이 켜질 때만 컴포넌트 틱을 킴
    if (RagdollComp->IsRagdoll())
    {
       HEROCOMP_LOG(Log, TEXT("래그돌 상태 진입: 카메라 보정을 위한 컴포넌트 틱 활성화"));
       PrimaryComponentTick.SetTickFunctionEnable(true);
    }
    else
    {
       HEROCOMP_LOG(Log, TEXT("래그돌 상태 해제: 카메라 위치 복구 및 컴포넌트 틱 비활성화"));
       PrimaryComponentTick.SetTickFunctionEnable(false);
       if (SpringArm)
       {
          SpringArm->AttachToComponent(Character->GetRootComponent(), FAttachmentTransformRules::SnapToTargetNotIncludingScale);
          SpringArm->SetRelativeLocation(FVector::ZeroVector);
       }
    }
}

void UParcelHeroComponent::Interact(const FInputActionValue& Value)
{
    if (!CanProcessLocalInput()) return;
    
    HEROCOMP_LOG(Log, TEXT("상호작용 조작(E키) 감지: InteractionComponent 호출"));
    
    // [Add] 방어 코드 : 캐릭터가 없으면 상호작용 중단
    
    ACharacter* Character = Cast<ACharacter>(GetOwner());
    if (!Character) return;
    
    // [Add] 방어 코드 : 래그돌 도중에는 상호작용 불가
    URagdollComponent* RagdollComp = Character->FindComponentByClass<URagdollComponent>();
    if (RagdollComp && RagdollComp->IsRagdoll()) return;

    // 만약 이미 상자를 들고 있다면 내려놓기(Drop) 실행
    if (UCharacterCarryComponent* CarryComp = Character->FindComponentByClass<UCharacterCarryComponent>())
    {
        if (CarryComp->IsCarrying())
        {
            HEROCOMP_LOG(Log, TEXT("이미 상자를 운반 중: 내려놓기(Drop) 실행"));
            
            // [방어코드] : 던지기 충전 중이었다면 충전 상태 해제
            if (bIsChargingThrow)
            {
                bIsChargingThrow = false;
                CurrentThrowChargeTime = 0.f;
                OnThrowChargeChanged.Broadcast(false, 0.0f);
                
                if (AParcelCharacter* ParcelChar = Cast<AParcelCharacter>(Character))
                {
                    if (UParcelPlayerStateComponent* StateComp = ParcelChar->GetParcelPlayerStateComponent())
                    {
                        StateComp->RemoveStateTag(FGameplayTag::RequestGameplayTag(TEXT("Character.Action.Throwing")));
                    }
                }

                if (!RagdollComp || !RagdollComp->IsRagdoll())
                {
                    PrimaryComponentTick.SetTickFunctionEnable(false);
                }
            }
            
            CarryComp->Drop();
            return;
        }
    }

    // InAir 상태에서는 집기 상호작용 불가
    if (AParcelCharacter* ParcelChar = Cast<AParcelCharacter>(Character))
    {
        if (UParcelPlayerStateComponent* StateComp = ParcelChar->GetParcelPlayerStateComponent())
        {
            if (StateComp->HasStateTag(FGameplayTag::RequestGameplayTag(TEXT("Character.State.InAir")))) return;
        }
    }
    
    // 장착된 InteractionComponent 호출
    if (AParcelCharacter* OwnerChar = Cast<AParcelCharacter>(GetOwner()))
    {
       if (UParcelInteractionComponent* InteractComp = OwnerChar->GetParcelInteractionComponent())
       {
          InteractComp->PrimaryInteract();
       }
    }
}


void UParcelHeroComponent::ServerSetSprinting_Implementation(bool bNewIsSprinting)
{
    if (bNewIsSprinting)
    {
        if (AParcelCharacter* ParcelChar = Cast<AParcelCharacter>(GetOwner()))
        {
            if (UParcelPlayerStateComponent* StateComp = ParcelChar->GetParcelPlayerStateComponent())
            {
                if (StateComp->HasStateTag(FGameplayTag::RequestGameplayTag(TEXT("Character.State.Exhausted"))))
                {
                    HEROCOMP_LOG(Warning, TEXT("[Server] %s 가 탈진 중 스프린트 패킷을 발송."), *ParcelChar->GetName());
                    return;
                }
            }
        }
    }
    
    HEROCOMP_LOG(Log, TEXT("[Server] 클라이언트의 요청으로 달리기 상태 변경 적용: %s"), bNewIsSprinting ? TEXT("True") : TEXT("False"));
    ApplySprintSpeed(bNewIsSprinting);
}

void UParcelHeroComponent::ApplySprintSpeed(bool bNewIsSprinting)
{
    AParcelCharacter* ParcelChar = Cast<AParcelCharacter>(GetOwner());
    if (!ParcelChar) return;

    // 서버 전용 권한 확인 후 중앙 상태 창고 컴포넌트에 실시간 달리기 태그 토글 제어
    if (ParcelChar->HasAuthority())
    {
       if (UParcelPlayerStateComponent* StateComp = ParcelChar->GetParcelPlayerStateComponent())
       {
          FGameplayTag SprintTag = FGameplayTag::RequestGameplayTag(TEXT("Character.State.Sprinting"));
          if (bNewIsSprinting) StateComp->AddStateTag(SprintTag);
          else StateComp->RemoveStateTag(SprintTag);
       }
        
        // MovementStat을 담당하는 매니저에 속도 계산 위임
        if (UParcelMovementStatComponent* StatComp = ParcelChar->GetParcelMovementStatComponent())
        {
            StatComp->RefreshMoveSpeed();
        }
    }
}

void UParcelHeroComponent::AddInputMappingContext()
{
    ACharacter* Character = Cast<ACharacter>(GetOwner());
    if (!Character || !Character->IsLocallyControlled()) return;

    APlayerController* PlayerController = Cast<APlayerController>(Character->GetController());
    if (!PlayerController || !PlayerController->GetLocalPlayer()) return;

    UEnhancedInputLocalPlayerSubsystem* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(PlayerController->GetLocalPlayer());
    if (!Subsystem || !InputMappingContext) return;

    Subsystem->RemoveMappingContext(InputMappingContext);
    Subsystem->AddMappingContext(InputMappingContext, 0);
}

bool UParcelHeroComponent::CanProcessLocalInput() const
{
    ACharacter* Character = Cast<ACharacter>(GetOwner());
    return Character && Character->GetController() && Character->IsLocallyControlled();
}

void UParcelHeroComponent::EnterRagdollCameraMode()
{
	PrimaryComponentTick.SetTickFunctionEnable(true);
	HEROCOMP_LOG(Log, TEXT("카메라 래그돌 모드 진입"));
}

void UParcelHeroComponent::ExitRagdollCameraMode()
{
	PrimaryComponentTick.SetTickFunctionEnable(false);
	ResetCameraAttachment();
	HEROCOMP_LOG(Log, TEXT("카메라 래그돌 모드 해제"));
}

void UParcelHeroComponent::StartThrow(const FInputActionValue& Value)
{
    if (!CanProcessLocalInput()) return;

    ACharacter* Character = Cast<ACharacter>(GetOwner());
    if (!Character) return;

    UCharacterCarryComponent* CarryComp = Character->FindComponentByClass<UCharacterCarryComponent>();
    if (CarryComp && CarryComp->IsCarrying())
    {
        bIsChargingThrow = true;
        CurrentThrowChargeTime = 0.f;
        
        // 게이지 모으는 중 틱 활성화
        PrimaryComponentTick.SetTickFunctionEnable(true);
        HEROCOMP_LOG(Log, TEXT("던지기 충전 시작: 틱 활성화"));
        
        // [UI] 던지기 차징 게이지 브로드캐스트
        OnThrowChargeChanged.Broadcast(true, 0.0f);
        
        if (AParcelCharacter* ParcelChar = Cast<AParcelCharacter>(Character))
        {
            if (UParcelPlayerStateComponent* StateComp = ParcelChar->GetParcelPlayerStateComponent())
            {
                StateComp->AddStateTag(FGameplayTag::RequestGameplayTag(TEXT("Character.Action.Throwing")));
            }
        }
    }
}

void UParcelHeroComponent::ReleaseThrow(const FInputActionValue& Value)
{
    if (!bIsChargingThrow) return;

    ACharacter* Character = Cast<ACharacter>(GetOwner());
    if (!Character) return;

    UCharacterCarryComponent* CarryComp = Character->FindComponentByClass<UCharacterCarryComponent>();
    if (CarryComp && CarryComp->IsCarrying())
    {
        float ChargeRatio = FMath::Clamp(CurrentThrowChargeTime / MaxThrowChargeTime, 0.f, 1.f);
        float ForceMag = FMath::Lerp(MinThrowForce, MaxThrowForce, ChargeRatio);
        
        // 카메라의 조준 방향 계산 (약간 위로 향해 포물선을 그리도록 보정)
        FVector ThrowDir = FollowCamera->GetForwardVector();
        ThrowDir.Z += 0.2f;
        ThrowDir.Normalize();
        
        FVector ThrowForce = ThrowDir * ForceMag;
        CarryComp->Throw(ThrowForce);
        HEROCOMP_LOG(Log, TEXT("던지기 실행! 충전 비율: %f, 최종 힘: %f"), ChargeRatio, ForceMag);
    }

    // [UI] 던지기 차징 종료 브로드캐스트
    OnThrowChargeChanged.Broadcast(false, 0.0f);
    
    bIsChargingThrow = false;
    CurrentThrowChargeTime = 0.f;
    
    if (AParcelCharacter* ParcelChar = Cast<AParcelCharacter>(Character))
    {
        if (UParcelPlayerStateComponent* StateComp = ParcelChar->GetParcelPlayerStateComponent())
        {
            StateComp->RemoveStateTag(FGameplayTag::RequestGameplayTag(TEXT("Character.Action.Throwing")));
        }
    }

    URagdollComponent* RagdollComp = Character->FindComponentByClass<URagdollComponent>();
    bool bNeedsTick = RagdollComp && RagdollComp->IsRagdoll();
    if (!bNeedsTick)
    {
        PrimaryComponentTick.SetTickFunctionEnable(false);
    }
}

void UParcelHeroComponent::ServerSetJumping_Implementation(bool bNewIsJumping)
{
    ApplyJumpTag(bNewIsJumping);
}

void UParcelHeroComponent::ApplyJumpTag(bool bNewIsJumping)
{
    AParcelCharacter* ParcelChar = Cast<AParcelCharacter>(GetOwner());
    if (!ParcelChar) return;

    // 서버 전용 권한 확인 후 중앙 상태 창고 컴포넌트에 실시간 점프 태그 토글 제어
    if (ParcelChar->HasAuthority())
    {
        if (UParcelPlayerStateComponent* StateComp = ParcelChar->GetParcelPlayerStateComponent())
        {
            FGameplayTag JumpTag = FGameplayTag::RequestGameplayTag(TEXT("Character.Action.Jump"));
            
            if (bNewIsJumping)
            {
                StateComp->AddStateTag(JumpTag);
                HEROCOMP_LOG(Log, TEXT("[Server] 캐릭터에 'Character.Action.Jump' 태그 추가."));
            }
            else
            {
                StateComp->RemoveStateTag(JumpTag);
                HEROCOMP_LOG(Log, TEXT("[Server] 캐릭터의 'Character.Action.Jump' 태그 제거."));
            }
        }
    }
}

void UParcelHeroComponent::ToggleInGameMenu()
{
    UE_LOG(LogTemp, Warning, TEXT("[ESC Test] ToggleInGameMenu 함수가 정상적으로 호출되었습니다!"));

    if (!CanProcessLocalInput()) return;

    ACharacter* OwnerChar = Cast<ACharacter>(GetOwner());
    if (!OwnerChar) return;

    APlayerController* PC = Cast<APlayerController>(OwnerChar->GetController());
    if (!PC) return;
    
    if (ESCMenuRef && ESCMenuRef->IsValidLowLevel() && ESCMenuRef->IsInViewport())
    {
        ESCMenuRef->K2_OnMenuCloseStarted(); 
        ESCMenuRef = nullptr;
        return;
    }
    
    if (ESCMenuClass)
    {
        ESCMenuRef = CreateWidget<UParcelInGameESCMenuWidget>(PC, ESCMenuClass);
        if (ESCMenuRef)
        {
            ESCMenuRef->AddToViewport();
            ESCMenuRef->SetupMenu();
        }
    }
}

 

4. 각 메뉴 위젯 WBP 에셋 생성, ESCMenu와 OptionMenu 이어주기.


*개인 프로젝트 및 개인 공부*

1. 오랜만에(?) 개인 프로젝트 복귀해서, UI Framework 작업 전 코드 점검 후 리팩토링 작업 진행.

 

1) 로컬(지역) 변수 TObjectPtr를 일반 포인터(*) 정리

헤더의 멤버 변수와 달리, .cpp 파일 내부의 함수 안에서 임시로 생성되어 쓰이는 포인터 변수들은 언리얼 공식 표준에 따라 TObjectPtr<T> 대신 일반 포인터 T*로 전환해야 불필요한 래핑 오버헤드가 발생하지 않으므로, 포인터를 정리하였음.

 

2) 메인 메뉴 전용 게임모드 & 컨트롤러 분리

기존 코드에서 "맵 이름에 MainMenu가 포함되어 있는지 체크해서 HUD 띄우기" 로직을 완전히 걷어내기 위한 구조적 개편을 시행.

 

MainMenuGameMode (.h / .cpp)

메인 메뉴 전용 월드 규칙서. 플레이어 캐릭터를 스폰하지 않고, 위에서 만든 메인 메뉴 전용 컨트롤러를 장착.

 

MainMenuWidget.cpp 수정

메인 메뉴 위젯은 이제 일반 ADgaPlayerController 대신, 메인 메뉴 전용 컨트롤러인 MainMenuPlayerController를 가져오도록 바인딩 고침.

 

3) C++ 부모 클래스 바인딩을 통한 타입 안정성 확보

 

과거 자식 위젯 내부의 블루프린트 함수를 문자열로 원격 호출했던 리플렉션 방식(CallFunctionByNameWithArguments)을 지우고, 가볍고 안전한 C++ 부모 클래스를 도입.

 

QuestEntryWidget (.h)

WBP_QuestEntry 블루프린트의 뼈대가 될 가벼운 C++ 클래스.

 

QuestTrackerWidget 수정 (헤더 및 소스)

QuestEntryWidgetClass 변수가 무분별한 UUserWidget이 아닌, 방금 만든 QuestEntryWidget 클래스 규격만 상속받을 수 있도록 함.

 



*오늘의 총평*

팀프로젝트에 집중한 하루였다. 멀티플레이에 적합한 UI 디자인과 UI 위젯 C++ 코드 구현을 하면서 많은 것을 배웠다.

'TIL' 카테고리의 다른 글

07.19 TIL  (0) 2026.07.19
07.18 TIL  (0) 2026.07.18
07.15 TIL  (0) 2026.07.15
07.14 TIL  (0) 2026.07.14
07.13 TIL  (0) 2026.07.13