실습: 2인용 점령 포인트 게임 구현
31강. 실습: 2인용 점령 포인트 게임 구현
1. 이번 강의에서 해결할 문제
각 기능을 따로 만들었더라도 세션 생성, 참가, 로비 준비, 맵 이동, 점령 판정, 점수 복제와 승리 처리를 하나의 흐름으로 연결하면 새로운 문제가 생깁니다. 로비의 준비 상태가 Travel 뒤 사라지거나, 두 클라이언트가 각자 점령도를 계산하거나, 승리 UI가 서버의 실제 경기 상태보다 먼저 열리는 식입니다.
이번 실습에서는 클라이언트는 의도를 보내고 서버가 경기 결과를 결정하며, 지속 상태는 복제된 GameState와 Actor에서 읽는다는 원칙으로 2인용 점령 포인트 게임을 완성합니다.
2. 학습 목표
3. 핵심 개념
전체 네트워크 흐름
| 단계 | 요청·판정 위치 | 복제하거나 알릴 결과 |
|---|---|---|
| 세션 생성·검색·참가 | GameInstanceSubsystem | 참가 성공 후 Connect String으로 ClientTravel |
| 로비 준비 | 소유 PlayerController의 Server RPC → Lobby GameMode | PlayerState의 준비 상태 |
| 경기 맵 이동 | 서버 Lobby GameMode | ServerTravel로 모든 연결을 이동 |
| 점령 판정 | CapturePoint 서버 인스턴스 | 현재 팀과 점령 진행률 |
| 팀 점수·경기 단계 | Capture GameMode | Capture GameState의 점수와 단계 |
| 승리 UI | 각 로컬 PlayerController | 복제된 GameState를 읽어 로컬 UI 갱신 |
GameMode는 서버에만 존재하므로 클라이언트 Widget이 GameMode를 직접 읽지 않습니다. 서버 규칙은 GameMode에 두고, 클라이언트도 알아야 하는 팀 점수·경기 단계는 GameState에 둡니다. 개인 팀과 준비 여부는 PlayerState에 둡니다. Pawn은 이동과 점령 구역 진입 주체이며, PlayerController는 로컬 입력·UI와 소유 연결의 RPC 진입점을 담당합니다.
점령 진행률은 매 프레임 Multicast하지 않습니다. 서버가 고정 주기로 계산한 지속 상태를 Replicated 변수로 보내고, OnRep는 UI나 시각 효과를 갱신하는 신호로만 사용합니다. 승리 여부 역시 클라이언트가 점수로 임의 판정하지 않고 서버 GameMode가 결정합니다.
4. 단계별 실습
4-1. 로비 준비 상태와 경기 시작
파일 경로: Source/CaptureArena/Player/CapturePlayerState.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/PlayerState.h"
#include "CapturePlayerState.generated.h"
UCLASS()
class CAPTUREARENA_API ACapturePlayerState : public APlayerState
{
GENERATED_BODY()
public:
void SetReadyOnServer(bool bNewReady);
void SetTeamIdOnServer(int32 NewTeamId);
UFUNCTION(BlueprintPure)
bool IsReady() const { return bReady; }
UFUNCTION(BlueprintPure)
int32 GetTeamId() const { return TeamId; }
virtual void GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const override;
private:
UPROPERTY(ReplicatedUsing=OnRep_Ready)
bool bReady = false;
UPROPERTY(Replicated)
int32 TeamId = INDEX_NONE;
UFUNCTION()
void OnRep_Ready();
};
#include "Player/CapturePlayerState.h"
#include "Net/UnrealNetwork.h"
void ACapturePlayerState::SetReadyOnServer(bool bNewReady)
{
check(HasAuthority());
bReady = bNewReady;
OnRep_Ready(); // 리슨 서버의 로컬 UI도 같은 갱신 경로를 사용합니다.
}
void ACapturePlayerState::SetTeamIdOnServer(int32 NewTeamId)
{
check(HasAuthority());
TeamId = NewTeamId;
}
void ACapturePlayerState::OnRep_Ready()
{
// 실제 프로젝트에서는 UI Event Dispatcher 또는 HUD 갱신 함수를 호출합니다.
}
void ACapturePlayerState::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ACapturePlayerState, bReady);
DOREPLIFETIME(ACapturePlayerState, TeamId);
}
파일 경로: Source/CaptureArena/Player/CapturePlayerController.h
UFUNCTION(Server, Reliable)
void ServerSetReady(bool bReady);
void ACapturePlayerController::ServerSetReady_Implementation(bool bReady)
{
ACapturePlayerState* PS = GetPlayerState<ACapturePlayerState>();
if (!IsValid(PS)) return;
PS->SetReadyOnServer(bReady);
if (ALobbyGameMode* Lobby = GetWorld()->GetAuthGameMode<ALobbyGameMode>())
{
Lobby->TryStartMatch();
}
}
PlayerController는 해당 클라이언트가 소유하므로 Server RPC를 보낼 수 있습니다. 서버는 RPC 호출자를 다시 PlayerState로 찾아 상태를 변경합니다. 클라이언트가 보낸 PlayerState 포인터나 팀 번호를 그대로 신뢰하지 않습니다.
파일 경로: Source/CaptureArena/Game/LobbyGameMode.cpp
#include "Game/LobbyGameMode.h"
#include "Algo/AllOf.h"
#include "GameFramework/GameStateBase.h"
#include "Player/CapturePlayerState.h"
void ALobbyGameMode::PostLogin(APlayerController* NewPlayer)
{
Super::PostLogin(NewPlayer);
if (ACapturePlayerState* PS = NewPlayer->GetPlayerState<ACapturePlayerState>())
{
const int32 AssignedTeam = GameState->PlayerArray.Num() - 1;
PS->SetTeamIdOnServer(FMath::Clamp(AssignedTeam, 0, 1));
}
}
void ALobbyGameMode::TryStartMatch()
{
if (GameState->PlayerArray.Num() != 2) return;
const bool bAllReady = Algo::AllOf(
GameState->PlayerArray,
[](const APlayerState* BaseState)
{
const ACapturePlayerState* PS = Cast<ACapturePlayerState>(BaseState);
return IsValid(PS) && PS->IsReady();
});
if (bAllReady)
{
GetWorld()->ServerTravel(
TEXT("/Game/Maps/CaptureMap?listen"),
true); // seamless travel 사용
}
}
bUseSeamlessTravel = true를 Lobby GameMode에 설정하고 PlayerState의 유지 규칙을 확인합니다. 실제 온라인 서비스에서는 세션 참가 가능 여부와 Travel 상태도 함께 갱신해야 합니다. 두 번째 준비 RPC가 여러 번 와도 경기 시작을 한 번만 실행하도록 bTravelStarted 같은 서버 전용 가드를 추가합니다.
4-2. 복제할 경기 상태 정의
파일 경로: Source/CaptureArena/Game/CaptureGameState.h
UENUM(BlueprintType)
enum class ECaptureMatchPhase : uint8
{
Waiting,
Playing,
Finished
};
UCLASS()
class CAPTUREARENA_API ACaptureGameState : public AGameStateBase
{
GENERATED_BODY()
public:
void AddTeamScoreOnServer(int32 TeamId, int32 Amount);
void FinishMatchOnServer(int32 WinningTeamId);
UFUNCTION(BlueprintPure)
int32 GetTeamScore(int32 TeamId) const;
UPROPERTY(ReplicatedUsing=OnRep_MatchState, BlueprintReadOnly)
ECaptureMatchPhase MatchPhase = ECaptureMatchPhase::Waiting;
UPROPERTY(ReplicatedUsing=OnRep_MatchState, BlueprintReadOnly)
int32 WinnerTeamId = INDEX_NONE;
virtual void GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const override;
private:
UPROPERTY(ReplicatedUsing=OnRep_TeamScores)
TArray<int32> TeamScores = {0, 0};
UFUNCTION()
void OnRep_TeamScores();
UFUNCTION()
void OnRep_MatchState();
};
#include "Game/CaptureGameState.h"
#include "Net/UnrealNetwork.h"
void ACaptureGameState::AddTeamScoreOnServer(int32 TeamId, int32 Amount)
{
check(HasAuthority());
if (!TeamScores.IsValidIndex(TeamId) || Amount <= 0) return;
TeamScores[TeamId] += Amount;
OnRep_TeamScores();
}
void ACaptureGameState::FinishMatchOnServer(int32 WinningTeamId)
{
check(HasAuthority());
WinnerTeamId = WinningTeamId;
MatchPhase = ECaptureMatchPhase::Finished;
OnRep_MatchState();
}
int32 ACaptureGameState::GetTeamScore(int32 TeamId) const
{
return TeamScores.IsValidIndex(TeamId) ? TeamScores[TeamId] : 0;
}
void ACaptureGameState::OnRep_TeamScores()
{
// 로컬 HUD가 GameState의 현재 점수를 다시 읽도록 알립니다.
}
void ACaptureGameState::OnRep_MatchState()
{
// Finished라면 로컬 승패 UI를 표시하되 입력 차단은 서버 상태와 맞춥니다.
}
void ACaptureGameState::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ACaptureGameState, TeamScores);
DOREPLIFETIME(ACaptureGameState, MatchPhase);
DOREPLIFETIME(ACaptureGameState, WinnerTeamId);
}
팀이 두 개뿐인 입문 프로젝트라 TArray<int32>를 사용합니다. 팀 수가 동적으로 늘거나 항목별 변경 빈도가 높아지면 구조체 배열과 더 효율적인 복제 방식을 검토하되, 이번 과정에서는 Fast Array를 구현 주제로 확장하지 않습니다.
4-3. 서버 권한 점령 구역 구현
파일 경로: Source/CaptureArena/World/CapturePoint.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "CapturePoint.generated.h"
class UBoxComponent;
class ACaptureCharacter;
UCLASS()
class CAPTUREARENA_API ACapturePoint : public AActor
{
GENERATED_BODY()
public:
ACapturePoint();
virtual void BeginPlay() override;
virtual void GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const override;
protected:
UPROPERTY(VisibleAnywhere)
TObjectPtr<UBoxComponent> CaptureVolume;
UPROPERTY(EditDefaultsOnly, Category="Capture", meta=(ClampMin="1.0"))
float SecondsToCapture = 5.0f;
UPROPERTY(ReplicatedUsing=OnRep_CaptureState)
float CaptureProgress = 0.0f;
UPROPERTY(ReplicatedUsing=OnRep_CaptureState)
int32 ActiveTeamId = INDEX_NONE;
private:
UFUNCTION()
void HandleBeginOverlap(UPrimitiveComponent* Overlapped, AActor* Other,
UPrimitiveComponent* OtherComp, int32 BodyIndex,
bool bFromSweep, const FHitResult& SweepResult);
UFUNCTION()
void HandleEndOverlap(UPrimitiveComponent* Overlapped, AActor* Other,
UPrimitiveComponent* OtherComp, int32 BodyIndex);
UFUNCTION()
void OnRep_CaptureState();
void EvaluateCapture();
int32 FindSingleOccupyingTeam() const;
TSet<TWeakObjectPtr<ACaptureCharacter>> Occupants;
FTimerHandle CaptureTimer;
};
#include "World/CapturePoint.h"
#include "Components/BoxComponent.h"
#include "Game/CaptureGameMode.h"
#include "Net/UnrealNetwork.h"
#include "Player/CaptureCharacter.h"
#include "Player/CapturePlayerState.h"
#include "TimerManager.h"
ACapturePoint::ACapturePoint()
{
bReplicates = true;
SetReplicateMovement(false);
CaptureVolume = CreateDefaultSubobject<UBoxComponent>(TEXT("CaptureVolume"));
SetRootComponent(CaptureVolume);
CaptureVolume->SetCollisionProfileName(TEXT("Trigger"));
}
void ACapturePoint::BeginPlay()
{
Super::BeginPlay();
if (!HasAuthority()) return;
CaptureVolume->OnComponentBeginOverlap.AddDynamic(
this, &ThisClass::HandleBeginOverlap);
CaptureVolume->OnComponentEndOverlap.AddDynamic(
this, &ThisClass::HandleEndOverlap);
GetWorldTimerManager().SetTimer(
CaptureTimer, this, &ThisClass::EvaluateCapture, 0.25f, true);
}
void ACapturePoint::HandleBeginOverlap(
UPrimitiveComponent*, AActor* Other, UPrimitiveComponent*, int32,
bool, const FHitResult&)
{
if (ACaptureCharacter* Character = Cast<ACaptureCharacter>(Other))
{
Occupants.Add(Character);
}
}
void ACapturePoint::HandleEndOverlap(
UPrimitiveComponent*, AActor* Other, UPrimitiveComponent*, int32)
{
if (ACaptureCharacter* Character = Cast<ACaptureCharacter>(Other))
{
Occupants.Remove(Character);
}
}
int32 ACapturePoint::FindSingleOccupyingTeam() const
{
int32 FoundTeam = INDEX_NONE;
for (const TWeakObjectPtr<ACaptureCharacter>& Entry : Occupants)
{
const ACaptureCharacter* Character = Entry.Get();
if (!IsValid(Character) || Character->IsEliminated()) continue;
const ACapturePlayerState* PS =
Character->GetPlayerState<ACapturePlayerState>();
if (!IsValid(PS) || PS->GetTeamId() == INDEX_NONE) continue;
if (FoundTeam != INDEX_NONE && FoundTeam != PS->GetTeamId())
{
return INDEX_NONE; // 서로 다른 팀이 함께 있으면 경합 상태입니다.
}
FoundTeam = PS->GetTeamId();
}
return FoundTeam;
}
void ACapturePoint::EvaluateCapture()
{
check(HasAuthority());
for (auto It = Occupants.CreateIterator(); It; ++It)
{
if (!It->IsValid()) It.RemoveCurrent();
}
const int32 OccupyingTeam = FindSingleOccupyingTeam();
if (OccupyingTeam == INDEX_NONE) return;
if (ActiveTeamId != OccupyingTeam)
{
ActiveTeamId = OccupyingTeam;
CaptureProgress = 0.0f;
}
CaptureProgress = FMath::Clamp(
CaptureProgress + 0.25f / SecondsToCapture, 0.0f, 1.0f);
if (CaptureProgress >= 1.0f)
{
if (ACaptureGameMode* GM = GetWorld()->GetAuthGameMode<ACaptureGameMode>())
{
GM->AwardCapturePoint(ActiveTeamId);
}
CaptureProgress = 0.0f;
}
OnRep_CaptureState(); // 리슨 서버의 로컬 화면도 갱신합니다.
}
void ACapturePoint::OnRep_CaptureState()
{
// 머티리얼·월드 UI는 ActiveTeamId와 CaptureProgress만 읽습니다.
}
void ACapturePoint::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(ACapturePoint, CaptureProgress);
DOREPLIFETIME(ACapturePoint, ActiveTeamId);
}
겹침 Delegate와 타이머는 서버에서만 바인딩합니다. 클라이언트 충돌 결과는 지연·조작될 수 있으므로 점령 판정의 근거로 사용하지 않습니다. TWeakObjectPtr와 유효성 검사를 사용해 퇴장·파괴된 Pawn 참조를 정리합니다. 양 팀이 동시에 들어오면 진행을 멈추는 규칙도 서버 한 곳에만 존재합니다.
4-4. 점수와 승리 처리
파일 경로: Source/CaptureArena/Game/CaptureGameMode.cpp
void ACaptureGameMode::BeginPlay()
{
Super::BeginPlay();
if (ACaptureGameState* GS = GetGameState<ACaptureGameState>())
{
GS->MatchPhase = ECaptureMatchPhase::Playing;
}
}
void ACaptureGameMode::AwardCapturePoint(int32 TeamId)
{
ACaptureGameState* GS = GetGameState<ACaptureGameState>();
if (!IsValid(GS) || GS->MatchPhase != ECaptureMatchPhase::Playing) return;
GS->AddTeamScoreOnServer(TeamId, 1);
constexpr int32 WinningScore = 3;
if (GS->GetTeamScore(TeamId) >= WinningScore)
{
GS->FinishMatchOnServer(TeamId);
SetMatchState(MatchState::WaitingPostMatch);
}
}
예제의 MatchPhase 쓰기는 실제 코드에서 서버 전용 setter로 감싸 캡슐화하세요. 승리 뒤 CapturePoint가 계속 점수를 주지 않도록 GameState 단계를 먼저 검사합니다. 결과 화면은 Client RPC로 한 번 보여 줄 수도 있지만, 재접속·늦은 복제에도 현재 승리 상태를 복원하려면 GameState의 지속 상태가 기준이어야 합니다.
4-5. UI와 입력의 로컬 책임
각 클라이언트의 HUD는 다음 값만 읽습니다.
- 로컬 PlayerState: 내 팀과 준비 여부
- Capture GameState: 양 팀 점수, 경기 단계, 승리 팀
- CapturePoint: 현재 점령 팀과 진행률
Widget에서 Server RPC를 직접 구현하지 않고 소유 PlayerController의 공개 함수를 호출합니다. 승리 UI가 열리면 로컬 입력 모드를 UI로 바꿀 수 있지만, 서버의 Pawn 이동·발사 검증도 Finished 단계에서 거부해야 합니다. 화면을 가렸다는 이유만으로 치팅이 방지되지는 않습니다.
5. 코드가 동작하는 이유
세션과 Travel은 맵보다 오래 사는 GameInstanceSubsystem이 담당하고, 로비와 경기 규칙은 각 맵의 서버 GameMode가 담당합니다. 클라이언트가 알아야 하는 지속 상태는 GameState와 PlayerState에 복제되므로 GameMode에 접근할 필요가 없습니다.
점령 구역은 서버의 충돌 결과만 수집하고 고정 주기로 진행률을 변경합니다. 클라이언트에는 판정 요청 권한이 없으며 복제된 진행률만 보입니다. 점수와 승리도 GameMode가 한 번 결정한 뒤 GameState가 전달하므로 호스트와 참가자 모두 같은 결과를 보게 됩니다.
6. 자주 하는 실수와 해결법
7. 직접 실습
8. 이해 점검 질문 3개
9. 핵심 요약
실습: 2인용 점령 포인트 게임 구현 미니 퀴즈
선택 즉시 정답과 해설을 확인할 수 있습니다. 결과는 이 브라우저에만 저장됩니다.
학습을 마쳤나요?
직접 실습과 점검 질문까지 확인한 뒤 완료로 표시하세요.