TIL
07.31 TIL
2026. 7. 31. 17:41

일정표

시간 할 일 비고
08:30~10:00 코드카타, 오늘의 수학  
10:00~13:00 오전 스크럼, 멀티플레이 강의  
14:00~18:00 멀티플레이 강의  
19:00~21:00 멀티플레이 강의, 오후 스크럼, TIL  
21:00~22:00 운동  
22:00~23:30 개인 프로젝트  


*오늘의 코드카타*

 

문제. 공원 산책

더보기

지나다니는 길을 'O', 장애물을 'X'로 나타낸 직사각형 격자 모양의 공원에서 로봇 강아지가 산책을 하려합니다. 산책은 로봇 강아지에 미리 입력된 명령에 따라 진행하며, 명령은 다음과 같은 형식으로 주어집니다.

 

["방향 거리", "방향 거리" … ]

예를 들어 "E 5"는 로봇 강아지가 현재 위치에서 동쪽으로 5칸 이동했다는 의미입니다. 로봇 강아지는 명령을 수행하기 전에 다음 두 가지를 먼저 확인합니다.

주어진 방향으로 이동할 때 공원을 벗어나는지 확인합니다.

주어진 방향으로 이동 중 장애물을 만나는지 확인합니다.

위 두 가지중 어느 하나라도 해당된다면, 로봇 강아지는 해당 명령을 무시하고 다음 명령을 수행합니다.

공원의 가로 길이가 W, 세로 길이가 H라고 할 때, 공원의 좌측 상단의 좌표는 (0, 0), 우측 하단의 좌표는 (H - 1, W - 1) 입니다.

공원을 나타내는 문자열 배열 park, 로봇 강아지가 수행할 명령이 담긴 문자열 배열 routes가 매개변수로 주어질 때, 로봇 강아지가 모든 명령을 수행 후 놓인 위치를 [세로 방향 좌표, 가로 방향 좌표] 순으로 배열에 담아 return 하도록 solution 함수를 완성해주세요.

#include <string>
#include <vector>

using namespace std;

vector<int> solution(vector<string> park, vector<string> routes) {
    int H = park.size();
    int W = park[0].size();
    
    int r = 0;
    int c = 0;
    
    for (int i = 0; i < H; ++i)
    {
        for (int j = 0; j < W; ++j)
        {
            if (park[i][j] == 'S')
            {
                r = i;
                c = j;
                break;
            }
        }        
    }
    
    for (const string& route : routes)
    {
        char dir = route[0];
        int dist = stoi(route.substr(2));
        
        int dr = 0; 
        int dc = 0;
        
        if (dir == 'N')
        {
            dr = -1;
        }
        
        else if (dir == 'S')
        {
            dr = 1;
        }
        
        else if (dir == 'W')
        {
            dc = -1;
        }
        
        else if (dir == 'E')
        {
            dc = 1;
        }
        
        bool can_move = true;
        
        for (int step = 1; step <= dist; ++step)
        {
            int nr = r + dr * step;
            int nc = c + dc * step;
            
            if (nr < 0 || nr >= H || nc < 0 || nc >= W || 
               park[nr][nc] == 'X')
            {
                can_move = false;
                break;
            }
        }
        
        if (can_move)
        {
            r += dr * dist;
            c += dc * dist;
        }
        
    }
    
    return {r, c};
}

 

 

1) 시작 위치('S') 및 공원 크기 파악

park 배열을 순회하면서 시작 위치 'S'의 행(r)과 열(c) 좌표를 찾는다.

공원의 세로 길이, 가로 길이를 구한다.

 

2) 방향 매핑 (Direction Vectors)

'N' (북쪽) (-1, 0)

'S' (남쪽) (1, 0)

'W' (서쪽) (0, -1)

'E' (동쪽) (0, 1)

 

3) 수행 전 이동 검증

각 명령에서 방향 문자(예: 'E')와 이동 거리(예: 5)를 추출한다.

 

4) 1칸씩 가보면서 다음 두 가지를 검사한다.

이동 경로 중 공원 범위를 벗어나는가?

이동 경로 중 장애물을 만나는가?

이동 도중 단 한 칸이라도 조건을 위반하면 해당 명령 전체를 취소(무시)하고 현재 위치를 유지한다.

끝까지 문제가 없다면 이동을 확정짓고 로봇 강아지의 위치를 갱신한다.

 

 

 

 


*수학 공부*

주제 : FFT 바다 시뮬레이션

 

거대한 해일이나 넘실거리는 파도를 실시간으로 구현해내는 그래픽스 수학인 'FFT Ocean Simulation'에 대해 알아보자.

 

잔잔하게 물결치는 파도를 구현하기 위해서 사용하는 방법 중에서, 기존에 배웠던 '거스트너 파도(Gerstner Wave)'가 있었다. 여러 개의 사인(Sin) 곡선을 더하는 방식(Gerstner Waves)이었는데, 이 방식을 쓰지 않고 왜 FFT를 사용할까?

 

1. FFT를 쓰는 이유

 

거스트너 파도 & 사인파 연산의 한계: 거스트너 파도는 파도 10~20개를 더할 때는 잘 돌아가지만, 진짜 바다처럼 미세한 잔파도부터 집채만한 파도까지 수천 개의 주파수가 뒤섞인 바다를 표현하려 하면 O(N)의 계산량이 수만 개의 정점(Vertex)에 걸려 GPU가 박살 나게 된다. 또한 패턴이 계속 반복되어 '인공적인 타일' 느낌을 지울 수 없다.

 

FFT(고속 푸리에 변환): 수천 개의 파도 주파수가 섞인 데이터(주파수 영역)를 O(N log N) 의 연산 속도로 공간 영역(높이 지도 및 디스플레이스먼트 맵)으로 한 번에 변환해 낸다. 덕분에 수만 개의 파도 성분이 합성된 거대한 태풍 바다를 실시간 60fps로 렌더링할 수 있게 됐다.

 

2. FFT 바다 시뮬레이션의 4단계 수학 파이프라인

 

FFT 바다는 단순한 2D 높이(Z)값만 만드는 게 아니라, 파도가 위로 솟구치면서 끝이 날카롭게 모여드는 '초피 파도(Choppy Waves)'의 X, Y, Z 3D 위치 변위(Displacement)를 한꺼번에 만들어낸다.

 

1단계: 해양 스펙트럼 (Phillips Spectrum / JONSWAP)

 

바람의 속도, 바람의 방향, 중력을 변수로 넣으면 수학적으로 "어떤 주파수(파장)의 파도가 에너지를 얼마나 갖는가?"를 나타내는 통계적 스펙트럼 P_h(k) 함수가 나온다. 바람이 세게 불수록 거대한 파도(저주파)의 에너지가 커지고, 바람이 약하면 미세한 잔파도(고주파) 위주로 형성된다.

 

2단계: 시간의 흐름 분산 관계 (Dispersion Relation)

시간 t가 지남에 따라 파도가 앞으로 날아가는 속도를 정의. Deep Water 해양물리학 공식에 따라 파장의 길이에 비례해 파도의 각주파수 omega(k)가 결정된다.

이 공식을 통해 복소수 파동 h(k, t)가 시간에 따라 회전(Euler's Formula: e^{i*omega*t})하면서 파도가 자연스럽게 출렁거리게 되는 것이다.

 

 

3단계: IFFT (역 고속 푸리에 변환)

 

주파수 공간에서 시간 t에 따라 합성된 복소수 배열을 컴퓨트 셰이더(Compute Shader)가 2D IFFT 연산을 돌려 3D 위치 변위 텍스처(Displacement Texture)로 변환한다.

D_z(x, y, t) : 바다의 높이(Height)

D_x(x, y, t), D_y(x, y, t) : 파도 끝이 뾰족하게 꼬집혀 모여드는 수평 이동량(Choppiness)

 

 

4단계: 거품(Foam) 생성을 위한 야코비안 (Jacobian)

 

파도가 너무 강하게 쏠려 파도 끝부분이 서로 겹치거나 뒤집힐 때(파도가 부서질 때) 흰 거품(Foam)이 생긴다. 따라서, 이를 물리적으로 감지하기 위해 변위 벡터의 야코비 행렬식(Jacobian Determinant, J)을 계산한다.

 

J < 0 이 되는 지점: 파도 머리가 찌그러지며 붕괴한 지점이므로, 셰이더가 이 지점에 자동으로 흰 거품(Foam) 텍스처를 칠한다.

 

 

3. 언리얼 엔진 / C++ & HLSL 통합 파이프라인

 

프로그래머는 이 FFT를 CPU에서 계산하지 않는다. 데이터가 너무 크기 때문이다.

C++(CPU)은 바람과 중력 파라미터만 넘기고, GPU 컴퓨트 셰이더가 IFFT 텍스처를 구워낸 뒤, 머티리얼(Vertex Shader)과 물리(Buoyancy)가 이를 읽어 쓰는 구조이다.

[C++ (CPU)] --(바람속도, 방향)--> [Compute Shader (GPU)]
                                      │
                                (2D IFFT 연산)
                                      ▼
                      [Displacement / Normal / Foam Textures]
                                  ┌───┴───┐
                                  ▼       ▼
                       [Vertex Shader]  [C++ Physics]
                       (3D 파도 렌더링) (배/캐릭터 부력 연산)

 

[HLSL Compute Shader 개념: GPU 2D IFFT 버터플라이 연산 구조]

// GPU 컴퓨트 셰이더에서 실행되는 FFT 핑퐁(Ping-Pong) 가로/세로 패스 중 일부
RWTexture2D<float4> OutputTexture;
Texture2D<float4> InputTexture;

[numthreads(16, 16, 1)]
void CS_FFT_Horizontal(uint3 id : SV_DispatchThreadID)
{
    // 1. 버터플라이(Butterfly) 룩업 테이블을 참조하여 두 복소수 노드를 읽어옴
    float4 Butterfly = ButterflyTexture[uint2(Stage, id.x)];
    float2 TopVal = InputTexture[uint2(Butterfly.z, id.y)].xy;
    float2 BottomVal = InputTexture[uint2(Butterfly.w, id.y)].xy;

    // 2. 복소수 곱셈 (Twiddle Factor 연산)
    float2 Twiddle = Butterfly.xy;
    float2 W = float2(
        TopVal.x + Twiddle.x * BottomVal.x - Twiddle.y * BottomVal.y,
        TopVal.y + Twiddle.x * BottomVal.y + Twiddle.y * BottomVal.x
    );

    // 3. IFFT 결과 텍스처 갱신 (Displacement X, Y, Z 저장)
    OutputTexture[id.xy] = float4(W, 0.0, 1.0);
}

[C++ 물리 부력 연산 (Buoyancy) 활용] 배(Ship)나 플레이어가 물에 떠 있을 때, C++ 코드가 GPU에서 구워진 FFT Displacement 텍스처 픽셀을 CPU로 가볍게 읽어와(또는 CPU 상에서 동일한 FFT 씨앗 수식으로 높이만 샘플링) 배 밑바닥 여러 지점의 높이를 판별한 뒤 부력 충격량(Impulse)을 가해 배가 파도 기울기에 따라 출렁이게 만든다.

 

<FFT 정리>

 

 

주파수 공간: 파도를 개별 복소수 주파수 성분으로 다루어 O(N log N) IFFT로 공간 높이 맵으로 압축 해제한다.

 

Choppy 파도: 단순 상하 이동(Z)뿐만 아니라 좌우 이동(X, Y) 변위를 함께 주어 뾰족하게 모여드는 수평 파도를 표현한다.

 

Jacobian 거품: 야코비 행렬식이 0 이하로 떨어져 파도 면적이 음수가 되는(뒤집히는) 곳을 감지해 현실적인 파도 거품(Foam)을 자동으로 합성한다.

 


*[심화] 해상 전투 게임에서 FFT 사용하기

 

플레이어가 파도에 맞춰 기우뚱거리는 배 위를 직접 걸어 다니며 대포를 쏘는 시스템은 3D 물리 엔진과 그래픽스 수학의 완벽한 융합이 필요한 고난도 메커니즘이다. 이 시스템을 구현할 때 가장 많이 하는 실수는 "배 중앙에서 높이(Z)만 읽어와서 배 전체를 올리고 내리는 것"인데, 이렇게 하면 배가 위아래로만 요동치고 파도에 따른 기울어짐(Pitch & Roll)이 전혀 생기지 않게 된다.

 

실제 선박 또는 해적선처럼 파도의 경사에 따라 배가 기우뚱거리고, 플레이어가 그 위를 자연스럽게 걸어 다닐 수 있게 만드는 C++ 파이프라인과 로직을 알아보자.

 

 

1. 핵심 물리 구조: 다지점 프로브 시스템 (Multi-Point Probe)

 

배를 회전시키는 핵심은 부력(Buoyancy)을 한곳에 가하는 것이 아니라, 선체 아래 여러 지점에 나누어 가하는 것이다.

 

1) 선체 아래에 4~8개의 '테스트 포인트(Probe Socket)'를 배치:

선수(Bow / 앞), 선미(Stern / 뒤), 좌현(Port / 왼쪽), 우현(Starboard / 오른쪽) 및 중앙 4곳 등.

 

2) 지점별 부력 연산:

각 프로브 위치의 현재 월드 좌표 (X, Y, Z_probe)에서 파도의 높이 Z_water를 읽어온다.

프로브가 물속에 잠겼다면(Z_probe} < Z_water), 잠긴 깊이 h = Z_water - Z_probe에 비례하는 상향 부력(Upward Force)을 계산한다.

 

3) 토크(Torque)의 자연스러운 발생:

물체 중앙(질량 중심)이 아닌 외곽 지점에 상향 힘을 가하면, 물리학 법칙에 의해 자동으로 회전력(Torque)이 발생한다.

예를 들어, 배 앞에 거대한 파도가 덮쳐 선수(앞) 쪽 프로브가 물에 깊게 잠기면, 앞쪽에만 강한 부력이 가해져 배 머리가 위로 들리는 기우뚱함(Pitch)이 수학적으로 자동 완성된다.

 

 

2. CPU와 GPU의 장벽 넘기: FFT 파도 샘플링

 

여기서 프로그래머가 겪는 걸림돌이 하나 있다.

 

FFT 파도 렌더링: GPU 컴퓨트 셰이더에서 텍스처로 돌아감.

배의 물리 연산 (Buoyancy): CPU(물리 엔진)에서 돌아감.

 

이때, GPU 텍스처 데이터를 매 프레임 CPU로 읽어오려 하면(Readback) 심각한 병목(Staging Lag)이 생겨 배 물리가 프레임 밀림 현상을 겪게 된다.

 

[해결책 2가지]

 

CPU FFT 듀얼 평가기 (CPU FFT Evaluator): C++ 상에 FFT 파도의 핵심 수식(주요 파도 4~8개의 주파수와 시간 t)을 가볍게 구현해 두고, 프로브 8개의 위치에 대해서만 C++에서 즉시 Z_water 높이를 수학적으로 계산해 냄. (가장 빠르고 표준적인 방식).

 

FFT 렌더 텍스처 샘플링 (Small Lockless Buffer): GPU가 구워낸 FFT Displacement Map의 저해상도 버전을 핑퐁 버퍼로 CPU 메모리에 실시간 공유.

 

 

3. 언리얼 C++ 실전 구현: 부력 및 감쇄 솔버

 

물리 충돌이 켜진 배 컴포넌트(UStaticMeshComponent)에 다지점 부력을 가하고, 물의 저항(Damping)을 가해 배가 너무 팽이처럼 돌지 않게 잡는 언리얼 C++ 로직을 구현해보자.

// ShipActor.cpp
#include "Components/StaticMeshComponent.h"
#include "GameFramework/Actor.h"

void AShipActor::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);

    if (!ShipMesh || !ShipMesh->IsSimulatingPhysics()) return;

    // 선체 하단에 위치한 프로브 소켓 이름들 (에디터에서 Mesh에 미리 박아둠)
    TArray<FName> ProbeSocketNames = { TEXT("Probe_Bow"), TEXT("Probe_Stern"), TEXT("Probe_Port"), TEXT("Probe_Starboard") };

    float Gravity = 980.0f; // cm/s^2 (언리얼 단위)
    float BuoyancyCoefficient = 2.5f; // 부력 강도 조절 파라미터

    // 각 프로브 지점 순회
    for (const FName& SocketName : ProbeSocketNames)
    {
        FVector ProbeWorldPos = ShipMesh->GetSocketLocation(SocketName);

        // 1. 해당 (X, Y) 좌표에서의 현재 파도 높이(Z)를 수학적으로 샘플링
        float WaterHeight = GetFFTWaveHeightAt(ProbeWorldPos.X, ProbeWorldPos.Y, GetWorld()->GetTimeSeconds());

        // 2. 프로브가 물속에 얼마나 잠겼는지 깊이(Submerged Depth) 계산
        float SubmergedDepth = WaterHeight - ProbeWorldPos.Z;

        if (SubmergedDepth > 0.0f) // 물속에 잠겼을 때만 부력 적용
        {
            // 아르키메데스 원리: 잠긴 깊이에 비례하는 부력 계산
            FVector BuoyancyForce = FVector(0.0f, 0.0f, SubmergedDepth * BuoyancyCoefficient * Gravity);

            // 3. 배의 '특정 소켓 위치'에 힘을 가함 (이것이 회전 토크를 유발함)
            ShipMesh->AddForceAtLocation(BuoyancyForce, ProbeWorldPos);

            // 4. 수중 저항(Water Drag): 물속에 있는 부분의 속도를 억제하여 진동(Jittering) 방지
            FVector ProbeVelocity = ShipMesh->GetPhysicsLinearVelocityAtPoint(ProbeWorldPos);
            FVector DampingForce = -ProbeVelocity * 0.5f * SubmergedDepth; 
            ShipMesh->AddForceAtLocation(DampingForce, ProbeWorldPos);
        }
    }
}

 

4. 언리얼 엔진이 플레이어가 출렁이는 배 위에 서 있을 때 작동하는 방식

 

배가 파도에 따라 기우뚱거릴 때, 그 위에 서 있는 플레이어 캐릭터가 아래로 떨어지거나 미끄러지지 않고 배를 따라 자연스럽게 같이 움직여야한다. 언리얼 엔진의 UCharacterMovementComponent는 '상대 이동(Based Movement)' 시스템을 내장하고 있어서 쉽게 적용이 가능하다.

 

1) 배 메쉬의 충돌(Collision) 설정:

배 메쉬의 Collision Profile을 PhysicsActor로 설정.

Complex Collision As Simple 옵션을 켜서, 배의 정밀한 갑판 모양(경사, 계단, 대포 배치 등)대로 물리 충돌체가 씌워지도록 함.

 

2) 플레이어 캡슐과 바닥 감지 (Floor Trace):

플레이어 캐릭터가 배 갑판을 밟는 순간, 언리얼의 Character Movement는 레이캐스트를 통해 "내가 지금 움직이는 강체(Ship) 위에 서 있다!"라는 사실을 감지한다. 이후, MovementBase 변수에 ShipActor가 자동으로 등록되면서 플레이어의 좌표 연산이 월드 좌표계가 아니라 '배의 상대 좌표계(Local Space)'로 자동 변환된다.

 

3) 기우뚱거릴 때 플레이어 관성 보정:

배가 갑자기 45도로 기울어질 때 플레이어가 미끄러지는 것을 막으려면, CharacterMovementComponent에서 Impart Base Velocity 관련 옵션들을 체크해 줘야 한다.

 

이렇게 하면 배가 기울어져도 캐릭터는 갑판에 고정되어 걸어 다닐 수 있고, 배가 선회할 때 관성에 따라 자연스럽게 몸이 쏠리는 고품질 액션을 만들 수 있게 된다.

 

<[심화] 요약>

 

  • 부력 연산: 배 바닥 4~8개 지점(프로브)에서 FFT 파도 높이를 샘플링하여 AddForceAtLocation을 때린다 -> 자연스러운 기우뚱거림(Pitch/Roll) 완성
  • 수중 감쇄: 잠긴 깊이에 비례해 반대 방향 저항력(Damping)을 주어 배가 오뚝이처럼 튀는 것을 막는다.
  • 플레이어 탑승: 정밀한 갑판 충돌체(Complex Collision)를 씌워두면, 언리얼 엔진의 Based Movement 시스템이 배의 회전과 이동을 플레이어 좌표에 자동으로 상속시켜 준다.

 

 


*멀티플레이 강의*

 

4-1 : 게임 프로젝트 생성

 

간단하게 프로젝트를 생성하고, Third Person Pack과 Starter Pack 을 추가하였음.

 

 

4-2 : 게임플레이 프레임워크

 

1) Build.cs 파일 수정

"SMYDedicated" 추가. 헤더파일 인클루드시 경로를 찾을 수 있게 하기 위함임.

 

2) 게임모드 베이스 클래스 생성

 

3) 게임스테이트 베이스 클래스 생성

 

4) 플레이어스테이트 베이스 클래스 생성

 

5) 플레이어 컨트롤러 클래스 생성

 

6) 플레이어 캐릭터 클래스 생성

 

7) Input Action, Input Mapping Context 설정

 

8) * 데디케이티드 서버 환경 설정

Toolbar > Edit > Editor Preferences 에서 아래 옵션들을 체크

 

Allow Late Joining 체크하면 Add another client 버튼으로 클라 이언트 추가 가능.

Always on top 체크하면 새 클라이언트 화면이 항상 위에 뜸.

Multiplayer Options에서 추가적인 설정 가능 : Launch Separate Server, Run Under One Process

Net Mode는 Play as Client로 설정하기.

Numbers of Player를 2로 설정하기.

 

 

4-3 : 멀티플레이 디버그 로깅과 이벤트 함수

 

디버깅 로그를 살펴보기 위한 환경을 설정해야 합니다. 이번 절에서는 서버와 클라이언트의 로그를 한 번에 확인하기 위해 Launch Separate Server 는 false 로, Run Under One Process 는 true 로 설정해 둬야 한다.

 

1) 멀티플레이 디버깅용 로그 매크로 작성하기.

더보기
// Copyright Epic Games, Inc. All Rights Reserved.

#pragma once

#include "CoreMinimal.h"

#pragma region NetLogging

SMYDEDICATED_API DECLARE_LOG_CATEGORY_EXTERN(LogSMYNet, Log, All);

#define NETMODE_TCHAR ((GetNetMode() == ENetMode::NM_Client) ? *FString::Printf(TEXT("Client%02d"), UE::GetPlayInEditorID()) : ((GetNetMode() == ENetMode::NM_Standalone) ? TEXT("StandAlone") : TEXT("Server")))

#define FUNCTION_TCHAR (ANSI_TO_TCHAR(__FUNCTION__))

#define SMY_LOG_NET(LogCategory, Verbosity, Format, ...) UE_LOG(LogCategory, Verbosity, TEXT("[%s] %s %s"), NETMODE_TCHAR, FUNCTION_TCHAR, *FString::Printf(Format, ##__VA_ARGS__))

#pragma endregion
// Copyright Epic Games, Inc. All Rights Reserved.

#include "SMYDedicated.h"
#include "Modules/ModuleManager.h"

IMPLEMENT_PRIMARY_GAME_MODULE( FDefaultGameModuleImpl, SMYDedicated, "SMYDedicated" );

#pragma region NetLogging

DEFINE_LOG_CATEGORY(LogSMYNet);

#pragma endregion

2) GameMode, GameState, PlayerController, PlayerCharacter, PlayerState의 생성

각각의 액터는 어떤 PC에서 생성될까?

GameMode : 서버에서만!

각각의 클라이언트 : PlayerController, PlayerCharacter, GameState가 서버에서 생성되고, 각각의 것에 해당하는 복제본을 들고 있게 된다. 상대방 클라이언트의 PlayerCharacter, PlayerState 또한 클라이언트가 복제본을 들고 있게 된다.

더보기
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "SMYGameModeBase.generated.h"

/**
 * 
 */
UCLASS()
class SMYDEDICATED_API ASMYGameModeBase : public AGameModeBase
{
	GENERATED_BODY()
	
#pragma region AGameModeBase Override
	
public:
	ASMYGameModeBase();
	
#pragma endregion
};
#include "GameMode/SMYGameModeBase.h"
#include "SMYDedicated/SMYDedicated.h"

ASMYGameModeBase::ASMYGameModeBase()
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT(""));
}
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/GameStateBase.h"
#include "SMYGameStateBase.generated.h"

/**
 * 
 */
UCLASS()
class SMYDEDICATED_API ASMYGameStateBase : public AGameStateBase
{
	GENERATED_BODY()
	
#pragma region AGameStateBase Override
	
public:
	ASMYGameStateBase();
	
#pragma endregion
	
};
#include "GameState/SMYGameStateBase.h"
#include "SMYDedicated.h"

ASMYGameStateBase::ASMYGameStateBase()
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT(""));
}
#pragma once

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

/**
 * 
 */
UCLASS()
class SMYDEDICATED_API ASMYPlayerController : public APlayerController
{
	GENERATED_BODY()
	
#pragma region APlayerController Override
	
public:
	ASMYPlayerController();
	
#pragma endregion
	
};
#include "Character/SMYPlayerCharacter.h"
#include "EnhancedInputSubsystems.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "GameFramework/SpringArmComponent.h"
#include "Camera/CameraComponent.h"
#include "EnhancedInputComponent.h"

ASMYPlayerCharacter::ASMYPlayerCharacter()
{
	PrimaryActorTick.bCanEverTick = false;
	
	bUseControllerRotationPitch = false;
	bUseControllerRotationYaw = false;
	bUseControllerRotationRoll = false;
	
	GetCharacterMovement()->bUseControllerDesiredRotation = false;
	GetCharacterMovement()->bOrientRotationToMovement = true;
	GetCharacterMovement()->RotationRate = FRotator(0.0f, 540.0f, 0.0f);
	
	SpringArm = CreateDefaultSubobject<USpringArmComponent>(TEXT("SpringArm"));
	SpringArm->TargetArmLength = 400.f;
	SpringArm->bUsePawnControlRotation = true;
	SpringArm->SetupAttachment(GetRootComponent());
	
	Camera = CreateDefaultSubobject<UCameraComponent>(TEXT("Camera"));
	Camera->bUsePawnControlRotation = false;
	Camera->SetupAttachment(SpringArm, USpringArmComponent::SocketName);

}

void ASMYPlayerCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
	Super::SetupPlayerInputComponent(PlayerInputComponent);
	
	UEnhancedInputComponent* EIC = CastChecked<UEnhancedInputComponent>(PlayerInputComponent);
	EIC->BindAction(MoveAction, ETriggerEvent::Triggered, this, &ThisClass::HandleMoveInput);
	EIC->BindAction(LookAction, ETriggerEvent::Triggered, this, &ThisClass::HandleLookInput);
	EIC->BindAction(JumpAction, ETriggerEvent::Triggered, this, &ACharacter::Jump);
	EIC->BindAction(JumpAction, ETriggerEvent::Completed, this, &ACharacter::StopJumping);
}

void ASMYPlayerCharacter::BeginPlay()
{
	Super::BeginPlay();
	
	if (IsLocallyControlled() == true)
	{
		APlayerController* PC = Cast<APlayerController>(GetController());
		checkf(IsValid(PC) == true, TEXT("PlayerController is invalid."));
		UEnhancedInputLocalPlayerSubsystem* EILPS = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(PC->GetLocalPlayer());
		checkf(IsValid(EILPS) == true, TEXT("EnhancedInputLocalPlayerSubsystem is invalid."));
		EILPS->AddMappingContext(InputMappingContext, 0);
	}
}

void ASMYPlayerCharacter::HandleMoveInput(const FInputActionValue& InValue)
{
	if (IsValid(Controller) == false)
	{
		UE_LOG(LogTemp, Error, TEXT("Controller is invalid."));
		return;
	}
	
	const FVector2D InMovementVector = InValue.Get<FVector2D>();
	const FRotator ControlRotation = Controller->GetControlRotation();
	const FRotator ControlYawRotation(0.0f, ControlRotation.Yaw, 0.0f);
	const FVector ForwardDirection = FRotationMatrix(ControlYawRotation).GetUnitAxis(EAxis::X);
	const FVector RightDirection = FRotationMatrix(ControlYawRotation).GetUnitAxis(EAxis::Y);
	
	AddMovementInput(ForwardDirection, InMovementVector.X);
	AddMovementInput(RightDirection, InMovementVector.Y);
	
}

void ASMYPlayerCharacter::HandleLookInput(const FInputActionValue& InValue)
{
	if (IsValid(Controller) == false)
	{
		UE_LOG(LogTemp, Error, TEXT("Controller is invalid."));
		return;
	}
	
	const FVector2D InLookVector = InValue.Get<FVector2D>();
	
	AddControllerYawInput(InLookVector.X);
	AddControllerPitchInput(InLookVector.Y);
}
#include "Character/SMYPlayerCharacter.h"
#include "EnhancedInputSubsystems.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "GameFramework/SpringArmComponent.h"
#include "Camera/CameraComponent.h"
#include "EnhancedInputComponent.h"
#include "SMYDedicated.h"

ASMYPlayerCharacter::ASMYPlayerCharacter()
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT(""));
    ...

3) 플레이어의 접속을 막는 이벤트 함수

바로 AGameModeBase::PreLogin() 함수.

로그인 관련 로직이 진행되기 전에 접속을 막아야 하기 때문이다.

더보기
// GameModeBase.h

public:
	virtual void PreLogin(const FString& Options, const FString& Address, const FUniqueNetIdRepl& UniqueId, FString& ErrorMessage) override;
#include "GameMode/SMYGameModeBase.h"
#include "SMYDedicated/SMYDedicated.h"

ASMYGameModeBase::ASMYGameModeBase()
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT(""));
}

void ASMYGameModeBase::PreLogin(const FString& Options, const FString& Address, const FUniqueNetIdRepl& UniqueId,
	FString& ErrorMessage)
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT("Begin"));
	
	Super::PreLogin(Options, Address, UniqueId, ErrorMessage);
	
	ErrorMessage = TEXT("The Server is currently full. Please try again later.");
	
	SMY_LOG_NET(LogSMYNet, Log, TEXT("End"));
}

만약 에디터에서 StandAlone으로 실행하게 되면, 각 클라이언트에서 GameMode가 생성되므로 접속에 실패하게 된다.

 

4) 클라이언트 접속 후 서버에서의 NetConnection 개체(ClientConnection)가 생성되었는지 확인하는 함수

AGameModeBase::PostLogin() 함수를 사용한다.

플레이어의 로그인 성공 후에야 ClientConnection 개체가 생성되기 때문이다. 또한 GameMode 액터 개체는 서버에서만 생성되기 때문에 클라이언트 커넥션을 살펴보기에 가장 적합하다.

 

5) 클라이언트 접속 후 클라이언트에서의 NetConnection 개체(ServerConnection)가 생성되었는지 확인하는 함수

APlayerController의 멤버 함수 중 하나로, APlayerController::PostNetInit() 함수가 적절하다.

액터의 네트워크 관련 속성들이 모두 초기화 된 후에 호출되는 함수이기 때문이다. 따라서 NetConnection 관련 속성도 초기화 되었을 것이라 추측이 가능하다. 또한 플레이어 컨트롤러는 서버를 제외하면 Owning Client에서만 생성되기 때문에, 서버 커넥션을 살펴보기에 가장 적합하다고 볼 수 있다.

 

6) 접속에 따른 NetConnection 개체 생성 살펴보기

ClientConnection은 접속한 플레이어 수만큼 생성된다.

ServerConnection은 항상 1개 생성된다.

각각 GameModeBase, PlayerController에 코드를 삽입해서 로그를 찍어서 살펴보자.

더보기
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "SMYGameModeBase.generated.h"

/**
 * 
 */
UCLASS()
class SMYDEDICATED_API ASMYGameModeBase : public AGameModeBase
{
	GENERATED_BODY()
	
#pragma region AGameModeBase Override
	
public:
	ASMYGameModeBase();
	
	virtual void PreLogin(const FString& Options, const FString& Address, const FUniqueNetIdRepl& UniqueId, 
		FString& ErrorMessage) override;
	
	virtual APlayerController* Login(UPlayer* NewPlayer, ENetRole InRemoteRole, const FString& Portal,
		const FString& Options, const FUniqueNetIdRepl& UniqueId, FString& ErrorMessage) override;
	
	virtual void PostLogin(APlayerController* NewPlayer) override;
	
#pragma endregion
};
#include "GameMode/SMYGameModeBase.h"
#include "SMYDedicated/SMYDedicated.h"

ASMYGameModeBase::ASMYGameModeBase()
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT(""));
}

void ASMYGameModeBase::PreLogin(const FString& Options, const FString& Address, const FUniqueNetIdRepl& UniqueId,
	FString& ErrorMessage)
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT("Begin"));
	
	Super::PreLogin(Options, Address, UniqueId, ErrorMessage);
	
	ErrorMessage = TEXT("The Server is currently full. Please try again later.");
	
	SMY_LOG_NET(LogSMYNet, Log, TEXT("End"));
}

APlayerController* ASMYGameModeBase::Login(UPlayer* NewPlayer, ENetRole InRemoteRole, const FString& Portal,
	const FString& Options, const FUniqueNetIdRepl& UniqueId, FString& ErrorMessage)
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT("Begin"));
	
	APlayerController* LoginPlayerController = Super::Login(NewPlayer, InRemoteRole, Portal, Options, UniqueId, ErrorMessage);
	
	SMY_LOG_NET(LogSMYNet, Log, TEXT("End"));
	
	return LoginPlayerController;
}

void ASMYGameModeBase::PostLogin(APlayerController* NewPlayer)
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT("Begin"));
	
	Super::PostLogin(NewPlayer);
	
	UNetDriver* ServerNetDriver = GetNetDriver();
	if (IsValid(ServerNetDriver) == true)
	{
		if (ServerNetDriver->ClientConnections.Num() == 0)
		{
			SMY_LOG_NET(LogSMYNet, Log, TEXT("There is no client connection."));
		}
		else
		{
			for (const auto& ClientConnection : ServerNetDriver->ClientConnections)
			{
				if (IsValid(ClientConnection) == true)
				{
					SMY_LOG_NET(LogSMYNet, Log, TEXT("Client Connection: %s"), *ClientConnection->GetName());
				}
			}
		}
	}
	else
	{
		SMY_LOG_NET(LogSMYNet, Log, TEXT("ServerNetDriver is invalid"));
	}
	
	SMY_LOG_NET(LogSMYNet, Log, TEXT("End"));
}

 

더보기
#pragma once

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

/**
 * 
 */
UCLASS()
class SMYDEDICATED_API ASMYPlayerController : public APlayerController
{
    GENERATED_BODY()
    
#pragma region APlayerController Override
    
public:
    ASMYPlayerController();
    
    virtual void PostNetInit() override;
    
    virtual void OnActorChannelOpen(FInBunch& InBunch, UNetConnection* Connection) override;
    
#pragma endregion
    
};
#include "Controller/SMYPlayerController.h"
#include "SMYDedicated.h"

ASMYPlayerController::ASMYPlayerController()
{
    SMY_LOG_NET(LogSMYNet, Log, TEXT(""));
}

void ASMYPlayerController::PostNetInit()
{
    SMY_LOG_NET(LogSMYNet, Log, TEXT("Begin"));
    
    Super::PostNetInit();
    
    if (IsLocalController() == true)
    {
       UNetDriver* ClientNetDriver = GetNetDriver();
       if (IsValid(ClientNetDriver) == true)
       {
          UNetConnection* ServerConnection = ClientNetDriver->ServerConnection;
          if (IsValid(ServerConnection) == true)
          {
             SMY_LOG_NET(LogSMYNet, Log, TEXT("Server Connection: %s"), *ClientNetDriver->ServerConnection->GetName());
          }
          else
          {
             SMY_LOG_NET(LogSMYNet, Log, TEXT("There is no server connection."));
          }
       }
       else
       {
          SMY_LOG_NET(LogSMYNet, Log, TEXT("ClientNetDriver is invalid"));
       }
    }
    
    SMY_LOG_NET(LogSMYNet, Log, TEXT("End"));
}

void ASMYPlayerController::OnActorChannelOpen(FInBunch& InBunch, UNetConnection* Connection)
{
    SMY_LOG_NET(LogSMYNet, Log, TEXT("Begin"));
    
    Super::OnActorChannelOpen(InBunch, Connection);
    
    SMY_LOG_NET(LogSMYNet, Log, TEXT("End"));
}

7) 언리얼 네트워크에서 사용되는 데이터 단위

 

- NetConnection : 모든 멀티플레이 관련 데이터들이 드나드는 통로. 서버는 접속한 클라이언트의 갯수만큼 넷커넥션을 가진다. 클라이언트는 서버와의 통신을 위한 단 하나의 넷커넥션만 가진다.

 

- Channel : 넷커넥션 개체의 Channels라는 속성이 있다. 이를통해 넷커넥션 개체가 채널들을 관리함을 알 수 있다. 넷커넥션 개체는 ActorChannel, ControlChannel, VoiceChannel..들을 관리 중인데, 특히 이 중에서 ActorChannel은 Actor Replication과 관련된 채널이다.

 

- Packet : 네트워크에서 사용되는 통상적인 데이터 단위.

 

- Bunch : 언리얼에서 사용하는 특별한 패킷 단위.

 

 

8) AGameModeBase::StartPlay() 함수가 실행되지 않는다면?

더보기
#pragma once

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

/**
 * 
 */
UCLASS()
class SMYDEDICATED_API ASMYPlayerController : public APlayerController
{
	GENERATED_BODY()
	
#pragma region APlayerController Override
	
public:
	ASMYPlayerController();
	
	virtual void PostNetInit() override;
	
	virtual void OnActorChannelOpen(FInBunch& InBunch, UNetConnection* Connection) override;
	
	virtual void PostInitializeComponents() override;
	
	virtual void BeginPlay() override;
	
#pragma endregion
	
};
#include "Controller/SMYPlayerController.h"
#include "SMYDedicated.h"

ASMYPlayerController::ASMYPlayerController()
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT(""));
}

void ASMYPlayerController::PostNetInit()
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT("Begin"));
	
	Super::PostNetInit();
	
	if (IsLocalController() == true)
	{
		UNetDriver* ClientNetDriver = GetNetDriver();
		if (IsValid(ClientNetDriver) == true)
		{
			UNetConnection* ServerConnection = ClientNetDriver->ServerConnection;
			if (IsValid(ServerConnection) == true)
			{
				SMY_LOG_NET(LogSMYNet, Log, TEXT("Server Connection: %s"), *ClientNetDriver->ServerConnection->GetName());
			}
			else
			{
				SMY_LOG_NET(LogSMYNet, Log, TEXT("There is no server connection."));
			}
		}
		else
		{
			SMY_LOG_NET(LogSMYNet, Log, TEXT("ClientNetDriver is invalid"));
		}
	}
	
	SMY_LOG_NET(LogSMYNet, Log, TEXT("End"));
}

void ASMYPlayerController::OnActorChannelOpen(FInBunch& InBunch, UNetConnection* Connection)
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT("Begin"));
	
	Super::OnActorChannelOpen(InBunch, Connection);
	
	SMY_LOG_NET(LogSMYNet, Log, TEXT("End"));
}

void ASMYPlayerController::PostInitializeComponents()
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT("Begin"));
	
	Super::PostInitializeComponents();
	
	SMY_LOG_NET(LogSMYNet, Log, TEXT("End"));
}

void ASMYPlayerController::BeginPlay()
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT("Begin"));
	
	Super::BeginPlay();
	
	SMY_LOG_NET(LogSMYNet, Log, TEXT("End"));
}
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "SMYGameModeBase.generated.h"

/**
 * 
 */
UCLASS()
class SMYDEDICATED_API ASMYGameModeBase : public AGameModeBase
{
	GENERATED_BODY()
	
#pragma region AGameModeBase Override
	
public:
	ASMYGameModeBase();
	
	virtual void PreLogin(const FString& Options, const FString& Address, const FUniqueNetIdRepl& UniqueId, 
		FString& ErrorMessage) override;
	
	virtual APlayerController* Login(UPlayer* NewPlayer, ENetRole InRemoteRole, const FString& Portal,
		const FString& Options, const FUniqueNetIdRepl& UniqueId, FString& ErrorMessage) override;
	
	virtual void PostLogin(APlayerController* NewPlayer) override;
	
	virtual void StartPlay() override;
	
#pragma endregion
};
#include "GameMode/SMYGameModeBase.h"
#include "SMYDedicated/SMYDedicated.h"

ASMYGameModeBase::ASMYGameModeBase()
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT(""));
}

void ASMYGameModeBase::PreLogin(const FString& Options, const FString& Address, const FUniqueNetIdRepl& UniqueId,
	FString& ErrorMessage)
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT("Begin"));
	
	Super::PreLogin(Options, Address, UniqueId, ErrorMessage);
	
	ErrorMessage = TEXT("The Server is currently full. Please try again later.");
	
	SMY_LOG_NET(LogSMYNet, Log, TEXT("End"));
}

APlayerController* ASMYGameModeBase::Login(UPlayer* NewPlayer, ENetRole InRemoteRole, const FString& Portal,
	const FString& Options, const FUniqueNetIdRepl& UniqueId, FString& ErrorMessage)
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT("Begin"));
	
	APlayerController* LoginPlayerController = Super::Login(NewPlayer, InRemoteRole, Portal, Options, UniqueId, ErrorMessage);
	
	SMY_LOG_NET(LogSMYNet, Log, TEXT("End"));
	
	return LoginPlayerController;
}

void ASMYGameModeBase::PostLogin(APlayerController* NewPlayer)
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT("Begin"));
	
	Super::PostLogin(NewPlayer);
	
	UNetDriver* ServerNetDriver = GetNetDriver();
	if (IsValid(ServerNetDriver) == true)
	{
		if (ServerNetDriver->ClientConnections.Num() == 0)
		{
			SMY_LOG_NET(LogSMYNet, Log, TEXT("There is no client connection."));
		}
		else
		{
			for (const auto& ClientConnection : ServerNetDriver->ClientConnections)
			{
				if (IsValid(ClientConnection) == true)
				{
					SMY_LOG_NET(LogSMYNet, Log, TEXT("Client Connection: %s"), *ClientConnection->GetName());
				}
			}
		}
	}
	else
	{
		SMY_LOG_NET(LogSMYNet, Log, TEXT("ServerNetDriver is invalid"));
	}
	
	SMY_LOG_NET(LogSMYNet, Log, TEXT("End"));
}

void ASMYGameModeBase::StartPlay()
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT("Begin"));
	
	SMY_LOG_NET(LogSMYNet, Log, TEXT("End"));
}

GameMode::StartPlay() 함수가 호출되지 않으면, 모든 액터의 BeginPlay() 함수도 호출이 되지 않는다. 따라서 캐릭터를 움직일 수 없다!

 

9) AActor::BeginPlay() 함수의 호출 과정

그런데 클라이언트에는 게임모드 액터가 복제되지 않는다고 했는데, 어떻게 게임 시작 신호를 알고 시작할까?

 

사실 게임모드가 게임의 시작을 본인이 직접 하는 것이 아니라, 게임스테이트에게 명령을 내려서 게임을 시작하게 된다. 게임스테이트 액터는 각 클라이언트로 복제되기 때문에 게임 시작을 알릴 수 있게 된다. 즉, 게임 모드에게 명령을 받은 게임 스테이트는 월드에 존재하는 모든 액터들에게 BeginPlay() 함수를 호출하도록 지시한다.

 

 

10) GameState 클래스와 BeginPlay() 함수 호출 살펴보기

더보기
#pragma once

#include "CoreMinimal.h"
#include "GameFramework/GameStateBase.h"
#include "SMYGameStateBase.generated.h"

/**
 * 
 */
UCLASS()
class SMYDEDICATED_API ASMYGameStateBase : public AGameStateBase
{
	GENERATED_BODY()
	
#pragma region AGameStateBase Override
	
public:
	ASMYGameStateBase();
	
	virtual void HandleBeginPlay() override;
	
	virtual void OnRep_ReplicatedHasBegunPlay() override;
	
#pragma endregion
	
};
#include "GameState/SMYGameStateBase.h"
#include "SMYDedicated.h"

ASMYGameStateBase::ASMYGameStateBase()
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT(""));
}

void ASMYGameStateBase::HandleBeginPlay()
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT("Begin"));
	
	Super::HandleBeginPlay();

	SMY_LOG_NET(LogSMYNet, Log, TEXT("End"));
}

void ASMYGameStateBase::OnRep_ReplicatedHasBegunPlay()
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT("Begin"));
	
	Super::OnRep_ReplicatedHasBegunPlay();
	
	SMY_LOG_NET(LogSMYNet, Log, TEXT("End"));
}

서버에서 GameMode::StartPlay() 함수를 호출한다. -> GameMode::StartPlay()에서 GameState::HandleBeginPlay()를 호출한다.

 

HandleBeginPlay() 함수에는 bReplicatedHasBegunPlay 속성을 수정하여 클라이언트로의 Property Replication을 유도한다. 동시에 서버에서도 WorldSettings::NotifyBeginPlay() 함수를 호출해준다.

 

이후 Client에서도 GameState::OnRep_() 함수가 호출된다. 이 함수에서는 WorldSettings::NotifyBeginPlay()를 호출한다.

 

 

11) 엔진 소스코드를 통해 모든 액터의 AActor::BeginPlay() 호출 흐름도 살펴보기

bReplicatedHasBegunPlay = true;

클라이언트 모든 액터의 BeginPlay()함수가 호출되게끔 한다.

GetWorldSettings()->NotifyBeginPlay();

서버 모든 액터의 BeginPlay() 함수가 호출되게끔 한다.

// WorldSettings.cpp
void AWorldSettings::NotifyBeginPlay()
// Actor.cpp
void AActor::DispatchBeginPlay(bool bFromLevelStreaming)
// GameStateBase.cpp
void AGameStateBase::OnRep_ReplicatedHasBegunPlay()
{
if (bReplicatedHasBegunPlay && GetLocalRole() != ROLE_Authority)
{
GetWorldSettings()->NotifyBeginPlay();

 

12) 시작 관련 주요 이벤트 함수

 

PostNetInit() : 서버에서 해당 액터의 속성 등 변경된 내용 세팅이 모두 완료된 뒤 클라이언트에 복제되었을 때 호출되는 함수. 멀티플레이에서는 BeginPlay() 함수가 호출되려면 해당 액터에 대한 서버에서의 처리가 모두 완료되어야 한다. 그래서 PostNetInit() 함수가 항상 먼저 실행된 후 StartPlay() 함수에 의해 BeginPlay() 함수가 호출된다.

 

PostInitializeComponents() : 해당 액터에 부착된 컴포넌트가 모두 준비된 상태에 호출된다.

 

StartPlay() : 게임의 시작을 지시하는 함수.

 

BeginPlay() : 게임 모드의 StartPlay() 함수를 통해 게임이 시작될 때 모든 액터에서 호출 되는 함수.

 

 

13) APawn::PossessedBy() 함수와 APawn::Unpossessed() 함수 살펴보기

 

Owner 설정 로직은 멀티플레이에서 아주 중요한 로직이다. 이를 통해 해당 액터가 어떤 클라이언트의 넷커넥션에 속하게 되는지가 결정되기 때문이다.

더보기
void APawn::PossessedBy(AController* NewController)
{
SetOwner(NewController);
...
}

void APawn::UnPossessed()
{
AController* const OldController = Controller;
ForceNetUpdate();
SetPlayerState(nullptr);
SetOwner(nullptr);
Controller = nullptr;
...
}

 

14) Possess 과정 실습

virtual void OnPossess(APawn* InPawn) override;

void ASMYPlayerController::OnPossess(APawn* InPawn)
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT("Begin"));
	
	Super::OnPossess(InPawn);

	SMY_LOG_NET(LogSMYNet, Log, TEXT("End"));
}

클라이언트에서는 Possess() 함수가 호출되지 않음에 주의.

그렇다면 클라이언트에서 Owner는 어떻게 초기화 되는 걸까?

AActor::Owner 속성은 ReplicatedUsing 키워드가 달린 속성이라서, Owner가 초기화 되면 OnRep_Owner() 함수가 클라이언트에서 호출된다.

 

	virtual void PossessedBy(AController* NewController) override;
	
	virtual void OnRep_Owner() override;
	
	virtual void PostNetInit() override;
void ASMYPlayerCharacter::PossessedBy(AController* NewController)
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT("Begin"));
	
	AActor* OwnerActor = GetOwner();
	if (IsValid(OwnerActor) == true)
	{
		SMY_LOG_NET(LogSMYNet, Log, TEXT("OwnerActor Name: %s"), *OwnerActor->GetName());
	}
	else
	{
		SMY_LOG_NET(LogSMYNet, Log, TEXT("There is no OwnerActor."));
	}
	
	Super::PossessedBy(NewController);
	
	OwnerActor = GetOwner();
	if (IsValid(OwnerActor) == true)
	{
		SMY_LOG_NET(LogSMYNet, Log, TEXT("OwnerActor Name: %s"), *OwnerActor->GetName());
	}
	else
	{
		SMY_LOG_NET(LogSMYNet, Log, TEXT("There is no OwnerActor."));
	}
	
	SMY_LOG_NET(LogSMYNet, Log, TEXT("End"));
}

void ASMYPlayerCharacter::OnRep_Owner()
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT("Begin"));
	
	Super::OnRep_Owner();
	
	AActor* OwnerActor = GetOwner();
	if (IsValid(OwnerActor) == true)
	{
		SMY_LOG_NET(LogSMYNet, Log, TEXT("OwnerActor Name: %s"), *OwnerActor->GetName());
	}
	else
	{
		SMY_LOG_NET(LogSMYNet, Log, TEXT("There is no OwnerActor."));
	}
	
	SMY_LOG_NET(LogSMYNet, Log, TEXT("End"));
}
void ASMYPlayerCharacter::PostNetInit()
{
	SMY_LOG_NET(LogSMYNet, Log, TEXT("Begin"));
	
	Super::PostNetInit();
	
	SMY_LOG_NET(LogSMYNet, Log, TEXT("End"));
}

 

PossessedBy() 함수는 서버에서만 호출되며, Owner 속성이 수정된다.

서버에서 Owner 속성이 수정되면 OnRep_() 함수가 호출된다.

Owner 속성이 Replicated 되고 난 후에 PostNetInit() 함수가 호출된다. 즉, 모든 네트워크 관련 속성들이 초기화 된 후에 PostNetInit() 함수가 호출된다.

 

 

15) 매크로를 통한 로컬 롤과 리모트 롤 출력

더보기
#define LOCAL_ROLE_TCHAR *(UEnum::GetValueAsString(TEXT("Engine.E NetRole"), GetLocalRole()))

#define REMOTE_ROLE_TCHAR *(UEnum::GetValueAsString(TEXT("Engine. ENetRole"), GetRemoteRole()))

#define SMY_LOG_ROLE(LogCat, Verbosity, Format, ...) UE_LOG(LogCat, Verbosity, TEXT("[%s][%s/%s] %s %s"), NETMODE_TCHAR, LOCAL_ROLE_TCHAR, REMOTE_ROLE_TCHAR, FUNCTION_TCHAR, *FString::Printf(Format, ##__VA_ARGS__))

 

16) 빙의 전 후 캐릭터의 리모트 롤 비교

 

PlayerCharacter.cpp 파일에 새롭게 정의한 SMY_LOG_ROLE을 이용하여 로그를 찍어보았다.

->

 

Owner 속성 수정 전에는 RemoteRole이 Simulated. : 남의 것

Owner 속성 수정 후에는 RemoteRole이 Autonomous이다. : 내가 조종하는 것.

 

17) 기존에 작성했던 불필요한 로깅 코드 제거

 



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

1. 각 클래스별 RPC / Rep 방식 구분하기

 

1. Authority / Autonomous Proxy / Simulated Proxy

 

언리얼 엔진 네트워크에서 액터(Actor)는 "누가 주도권을 쥐고 있으며, 어떤 시점으로 바라보는가"에 따라 3가지 네트워크 역할(ENetRole)을 가진다.

  • Authority (권한 / 서버)
    • 원본(Original) 액터. 오직 서버(Server)에만 존재한다.
    • 게임 내 위치, 체력, 판정 등 모든 "원조 데이터(Source of Truth)"를 결정하고 통제한다.
  • Autonomous Proxy (자율 프록시 / 조종 중인 클라이언트)
    • 내(Local)가 직접 키보드/마우스로 조종하는 캐릭터클라이언트 복제본. (예: Client 1 화면에서의 Pawn 1)
    • 로컬 입력을 받아 즉각 반응(예측, Prediction)할 수 있으며, 서버로 "나 이렇게 움직였어요"라고 ServerRPC 요청을 보낼 권한을 가진다.
  • Simulated Proxy (시뮬레이션 프록시 / 관찰 중인 클라이언트)
    • 남(Other Player)이 조종하는 캐릭터를 내 화면에 띄우기 위한 복제본. (예: Client 2 화면에서의 Pawn 1)
    • 조종 권한이 전혀 없으며, 서버로부터 넘어오는 위치/애니메이션 복제(Replication) 데이터를 받아 부드럽게 보간(Interpolation) 연출만 시뮬레이션한다.

 

2. 점선과 실선의 의미

 

그림에서 선의 종류는 데이터의 물리적 존재 및 직접 소유 관계네트워크 동기화 흐름을 구분한다.

  • 실선 (Solid Line): 직접 소유(Ownership) 및 물리적 존재 관계
    • 객체 간의 명확한 소유권(Possession/Owner) 연결이나 클라이언트 로컬에 실제 인스턴스로 존재하는 강력한 연결을 뜻한다.
    • 예: Server의 PlayerController가 Pawn을 Possess(소유)하고 있는 관계, 로컬 클라이언트에 실제 생성되어 배치된 구조.
  • 점선 (Dotted/Dashed Line): 네트워크 복제(Replication) 및 간접 관찰 관계
    • 서버에서 클라이언트로 신호나 변수가 넘어가는 동기화(Replication/RPC) 통로이거나, 로컬 조종권 없이 단순히 시뮬레이션 정보만 받아보는 상태를 뜻한다.
    • 예: Client 1의 Pawn 1 정보가 Client 2의 Simulated Proxy로 주사되는 흐름.

3. ★서버에만 있는 클래스: GameMode (AGameModeBase)

  • 존재 위치: 오직 서버(Server) 메모리에만 생성되며, 모든 클라이언트(Client 1, Client 2...)에는 아예 존재하지 않는다 (Null).
  • 이유: 승패 판정, 스폰 위치 계산, 규칙 검증, 플레이어 접속/탈퇴 관리 등 게임의 핵심 규칙을 클라이언트에 넘겨주면 핵(Hack)이나 데이터 변조에 노출되기 때문.

4. GameState의 역할 (AGameStateBase)

  • 개념: GameMode가 서버 독점이라면, GameState는 전원 공유 방송국이다.
  • 역할: GameMode가 결정한 게임의 현재 상태(남은 시간, 팀 점수, 현재 라운드, 승패 결과 등)를 서버와 모든 클라이언트에게 동시에 복제(Replicate)하여 동기화한다.
  • 클라이언트 입장에서 "지금 몇 대 몇이지?", "남은 시간이 얼마지?"를 확인하려면 반드시 GameState를 참조해야 한다.

5. Client 1의 신호를 Client 2가 받는 전체 과정

 

언리얼 엔진은 P2P(클라이언트끼리 직접 연결)가 아닌 C/S(Client-Server) 중앙 집중 구조이다. 따라서 Client 1과 Client 2는 절대로 직접 대화할 수 없다.

 

1) 방법 1: Replicated 또는 NetMulticast 사용하기

[Client 1] ──(1. ServerRPC)──> [Server] ──(2. Replication / RPC)──> [Client 2]

 

  • 신호 발신 (Client 1 ➔ Server):
    • Client 1이 공격 버튼을 누르면, 자신의 Autonomous Proxy (또는 PlayerController)를 통해 서버로 ServerRPC 신호를 쏘아 올린다.
  • 서버 판정 및 상태 변경 (Server):
    • Authority를 가진 서버가 해당 요청을 검증(거리, 쿨타임 등)한 뒤, 서버 상의 Pawn 1 위치나 체력, 상태 변수를 변경한다.
  • 신호 전파 및 복제 (Server ➔ Client 2):
    • 서버는 변경된 변수(Replicated)나 효과 연출(NetMulticast 또는 복제 속성)을 네트워크망을 통해 Client 2로 전파한다.
  • 시뮬레이션 연출 (Client 2):
    • Client 2에 존재하는 Client 1의 Simulated Proxy가 서버로부터 전달받은 데이터를 적용하여 화면에 공격 애니메이션이나 이동을 시뮬레이션한다.

 

2) ★방법 2: GameState 변수 복제를 통한 신호 전달하기

 

 

언리얼 엔진의 GameState는 모든 클라이언트에 복제본이 존재합니다. 따라서 아래와 같은 4단계 과정을 거쳐 Client 1의 신호가 Client 2에게 전달된다.

[Client 1] 
   │
   │ 1. ServerRPC 호출 (PlayerController / Pawn을 거쳐서)
   ▼
[Server]
   │
   │ 2. GameState 내의 Replicated (또는 RepNotify) 변수 값 변경
   ▼
[GameState on Server]
   │
   │ 3. Engine의 Property Replication (자동 변수 동기화)
   ▼
[GameState on Client 2]
   │
   │ 4. 변수 값 업데이트 (또는 OnRep_ 콜백 함수 자동 실행)
   ▼
[Client 2 화면 및 UI 갱신]

 

  1. Client 1 → Server: Client 1이 입력(예: 턴 종료, 점수 획득, 채팅 전송)을 진행하면, 자신의 PlayerControllerPawn을 통해 ServerRPC로 서버에 알린다.
  2. Server 판정 및 변수 수정: 서버는 검증 후 GameState에 존재하는 복제 변수(UPROPERTY(ReplicatedUsing = OnRep_...))의 값을 변경한다.
  3. Server → Client 2 (Replication): 언리얼 엔진의 네트워크 드라이버가 변경된 GameState 변수 값을 Client 2(및 접속된 모든 클라이언트)의 GameState 복제본으로 자동으로 쏴준다.
  4. Client 2 수신 및 처리: Client 2의 GameState 변수 값이 바뀌면서 OnRep_ 콜백 함수가 실행되어 화면/UI/이펙트가 갱신된다.

 

3) NetMulticast 방식 vs. GameState 변수 복제 방식

가장 큰 차이는 '지속성'이다.

구분 NetMulticast RPC 방식 GameState 변수 복제 (Replication) 방식
성격 휘발성 이벤트 (Event) 지속적 상태 (State)
신호 전달 방식 서버가 순간적으로 명령을 쏘고 끝남 서버 변수가 바뀌면 클라이언트에 반영됨
뒤늦게 접속한 유저 (Late-Joiner) 신호를 받지 못함 (이미 지나간 방송) 접속하자마자 최신 상태를 받아옴
주요 용도 사운드 재생, 폭발 이펙트 등 일회성 연출 남은 시간, 현재 턴 유저, 게임 점수, 승패 상태

 

만약 라운드가 진행 중일 때 Client 3이 새로 접속했다고 가정해 보자.

 

* NetMulticast RPC: 접속 전에 실행되었기 때문에 Client 3은 아무 정보도 받지 못해 게임 상태가 꼬이게 된다.

 

* GameState 변수 복제: Client 3이 접속하는 순간, 서버의 GameState 최신 변수 상태를 통째로 복제받아 즉시 현재 턴 및 남은 시간을 정상 표시할 수 있다.

 

다만 GameState 변수 복제 방식을 사용할 때 주의할 점은 '네트워크 소유권'을 명확하게 이해하고 있어야 한다는 것이다.

 

GameState는 클라이언트 소유가 아니다 : GameState는 서버가 관리하는 공유 객체이므로, 클라이언트는 GameState 내부에서 직접 ServerRPC를 호출할 수 없다. (호출 시 서버에서 무시됨)

 

따라서, 반드시 클라이언트가 소유권을 가진 PlayerController나 Pawn에서 ServerRPC를 먼저 부르고, 그 ServerRPC 안에서 서버의 GameState 변수를 변경해야 한다.

 


*오늘의 총평*

강의가 생각 외로 어려운 내용이어서 잘 정리하고 명확하게 이해하기 위해 추가 공부를 하느라 시간이 좀 더 소요되었다. 계획대로 진도를 나가지는 못했지만 그래도 열심히 학습했다. 주말에도 좀 더 강의를 듣고, 실습하고, 복습해서 오늘 계획대로 못한 부분을 채워놓기로 했다.

'TIL' 카테고리의 다른 글

07.30 TIL  (0) 2026.07.30
07.29 TIL  (0) 2026.07.29
07.28 TIL  (0) 2026.07.28
07.27 TIL  (0) 2026.07.27
07.24 TIL  (0) 2026.07.24