게임 모드, 스테이트 블루프린트
GameMode·GameState·PlayerState가 규칙과 공유 상태, 플레이어별 상태를 나누는 프레임워크 구조를 익힙니다.
캐릭터 애니메이션, UI, AI를 하나씩 만들고 나면 이것들을 묶을 자리가 필요해집니다. 몇 점을 얻어야 이기는지, 지금 몇 라운드인지, 누가 몇 점을 냈는지 같은 값을 아무 액터에나 두면, 어느 블루프린트가 규칙을 판단하고 어느 블루프린트가 값을 들고 있는지 금방 알 수 없게 됩니다. 멀티플레이어 게임이라면 더 곤란합니다. 서버와 클라이언트가 각자 다른 값을 보고 있을 수 있기 때문입니다.
언리얼 엔진은 이 문제를 풀기 위해 게임플레이 프레임워크(Gameplay Framework)라는 미리 정의된 클래스 구조를 제공합니다. 이 글에서는 그 가운데 게임의 규칙을 맡는 게임 모드(Game Mode), 게임 전체의 공유 상태를 맡는 게임 스테이트(Game State), 플레이어 한 명의 상태를 맡는 플레이어 스테이트(Player State)를 살펴보고, 각각을 블루프린트로 만들어 연결하는 방법을 익힙니다.
게임 모드: 서버에서 판단하는 규칙
게임 모드는 게임의 규칙을 정의하고, 그 게임이 어떤 방식으로 진행되는지를 제어하는 클래스입니다. 공식 문서에 따르면 게임 모드는 멀티플레이어 게임에 참가한 원격 클라이언트에 복제되지 않고 서버에만 존재합니다. 싱글플레이어의 Standalone 월드에서는 그 게임 자체가 서버 역할도 하므로 게임 모드가 있습니다. 그래서 게임 모드에 두는 로직은 모든 플레이어에게 똑같이 적용되는 규칙, 그리고 서버가 판단해야 하는 일이어야 합니다.
게임 모드가 맡는 일은 다음과 같습니다.
- 게임 규칙: 승리 조건, 패배 조건, 점수 계산 방식처럼 게임의 핵심 규칙을 정합니다.
- 기본 클래스 지정: 그 게임에서 쓸 기본 폰(Default Pawn Class), 플레이어 컨트롤러(Player Controller Class), HUD 클래스, 게임 스테이트 클래스, 플레이어 스테이트 클래스를 지정합니다.
- 플레이어 스폰: 플레이어가 게임에 들어오면 스폰할 위치(Player Start)를 고르고 폰을 스폰합니다.
- 게임 흐름: 라운드의 시작과 끝, 게임의 시작과 끝, 일시정지처럼 게임 전체의 흐름을 관리합니다.
- 접속과 연결 해제: 플레이어가 서버에 접속하거나 연결이 끊겼을 때의 처리를 맡습니다.
싱글플레이어 게임이 아주 단순하다면 게임 스테이트를 따로 두지 않고 게임 모드가 직접 게임 상태를 관리하기도 합니다. 다만 나중에 UI가 그 상태를 읽어야 하거나 멀티플레이어로 넓힐 가능성이 있다면, 처음부터 상태를 게임 스테이트에 두는 편이 옮길 일이 적습니다.
게임 모드 블루프린트 만들기
먼저 콘텐츠 브라우저에서 마우스 오른쪽 버튼을 눌러 Blueprint Class를 고르고, 부모 클래스로 GameModeBase나 GameMode를 선택합니다. GameModeBase는 모든 게임 모드의 기본이 되는 클래스이며, 공식 문서는 단순하고 효율적이라는 이유로 새 프로젝트의 기본값으로 이 클래스를 씁니다. GameMode는 GameModeBase의 자식 클래스로, 매치 상태(match state)와 그 흐름을 추가합니다. 매치 상태는 맵에 들어오는 중(EnteringMap), 시작을 기다리는 중(WaitingToStart), 진행 중(InProgress), 매치가 끝난 뒤(WaitingPostMatch) 같은 단계를 말하며, 멀티플레이어 슈터처럼 매치 단위로 진행되는 게임에 알맞습니다. 이름은 GM_MyGame처럼 지어 둡니다.
다음으로 이 게임 모드를 프로젝트 전체의 기본값으로 지정합니다. Edit > Project Settings > Maps & Modes로 가서 Default GameMode 드롭다운에서 GM_MyGame을 선택합니다. 특정 레벨에서만 다른 게임 모드를 쓰고 싶다면, 그 레벨을 연 상태에서 World Settings 패널을 열고 Game Mode 섹션의 GameMode Override에 그 레벨에서 쓸 게임 모드를 지정합니다. 공식 문서는 레벨의 GameMode Override가 프로젝트 기본값보다 우선한다고 설명하므로, 프로젝트 설정을 바꿨는데 어떤 레벨에서만 반영되지 않는다면 그 레벨의 World Settings부터 확인합니다.
게임 스테이트: 모두가 보는 게임 전체의 상태
게임 스테이트는 현재 게임 전체의 상태를 담아 클라이언트에 전달하는 클래스입니다. 서버에 있는 게임 스테이트는 클라이언트로 복제되므로, 남은 시간, 현재 라운드, 팀 점수, 처치 기록처럼 모든 플레이어가 볼 수 있어야 하는 정보를 두기에 알맞습니다. 공식 문서도 게임 스테이트에는 플레이하는 동안 바뀌고 모두에게 보여야 하는 속성을 두라고 안내합니다.
다만 게임 스테이트 액터가 복제된다고 거기에 만든 변수까지 저절로 복제되는 것은 아닙니다. 직접 만든 변수는 따로 복제를 설정하고, 서버에서 값을 바꿔야 클라이언트에 전달됩니다. 또 복제는 네트워크를 거쳐 도착하므로, 모든 클라이언트가 같은 순간에 새 값을 받는다는 뜻도 아닙니다. 처치 기록 같은 공유 데이터도 필요한 형태를 직접 정의하고 복제해야 하며, 게임 스테이트가 영구 저장이나 지난 이벤트 재생까지 해 주는 로그 시스템은 아닙니다. 복제를 설정하는 방법은 블루프린트에서의 리플리케이션에서 다룹니다.
게임 스테이트 블루프린트도 같은 방식으로 만듭니다. 콘텐츠 브라우저에서 Blueprint Class를 고르고 부모 클래스로 GameStateBase나 GameState를 선택한 뒤, GS_MyGame처럼 이름을 짓습니다. GameStateBase는 기본 기능을 제공하고, GameState는 GameMode의 매치 상태와 연결되는 기능을 더한 자식 클래스입니다. 공식 문서는 GameMode를 상속했다면 게임 스테이트도 GameState를 상속하라고 안내하므로, GameModeBase와 GameStateBase, GameMode와 GameState를 짝지어 씁니다.
만든 게임 스테이트는 게임 모드에 연결해야 쓰입니다. GM_MyGame을 열고 툴바의 Class Defaults를 누른 뒤, 디테일 패널의 Classes 섹션에서 Game State Class를 GS_MyGame으로 바꿉니다.
플레이어 스테이트: 플레이어 한 명의 상태
플레이어 스테이트는 플레이어 한 명의 현재 상태를 담아 복제하는 클래스입니다. 플레이어 스테이트도 서버에서 클라이언트로 복제되므로, 점수, 이름, 처치 횟수, 현재 팀처럼 그 플레이어만의 정보이면서 다른 플레이어에게도 보여야 하는 값을 두기에 알맞습니다. 스코어보드가 대표적인 예입니다. 게임 스테이트의 PlayerArray에는 접속한 모든 플레이어의 플레이어 스테이트가 들어 있어서, 스코어보드는 이 배열을 돌며 각 플레이어의 값을 읽을 수 있습니다.
플레이어 스테이트가 맡는 일은 다음과 같습니다.
- 플레이어별 정보: 점수, 처치와 사망 수, 플레이어 이름, 팀처럼 그 플레이어의 게임 데이터를 저장하고 복제합니다.
- 다른 플레이어와의 동기화: 멀티플레이어에서 다른 클라이언트에도 각 플레이어의 상태를 전달해, 서로의 점수나 상태를 볼 수 있게 합니다.
- 폰과 분리된 정보: 플레이어 스테이트는 폰과 따로 존재하므로, 같은 플레이어의 폰을 새로 교체해도 기존 플레이어 스테이트의 점수를 유지할 수 있습니다.
체력처럼 폰과 플레이어 스테이트 가운데 어디에 둘지 헷갈리는 값도 있습니다. 체력을 다른 플레이어에게 보여 줘야 하고 재스폰 뒤에도 이어져야 한다면 플레이어 스테이트가 맞지만, 폰이 죽으면 함께 사라지는 체력이라면 폰에 두는 경우가 많습니다. 어느 쪽이든 그 값이 언제까지 살아 있어야 하는지를 기준으로 고릅니다. 또 폰과 분리되어 있다는 것이 디스크 저장, 재접속, 모든 레벨 이동에서까지 값이 보존된다는 뜻은 아닙니다. 그런 영구 저장은 체크포인트와 세이브 설계처럼 따로 설계해야 합니다.
플레이어 스테이트 블루프린트는 부모 클래스로 PlayerState를 선택해 만들고, PS_MyPlayer처럼 이름을 짓습니다. 그리고 GM_MyGame의 Class Defaults에서 Classes 섹션의 Player State Class를 PS_MyPlayer로 바꿉니다.
세 클래스의 관계
RULES · SHARED STATE · PLAYER STATE
판단할 규칙과 전달할 상태의 책임을 나눕니다
서버는 GameMode에서 게임 규칙을 판단합니다. 클라이언트가 알아야 할 게임 전체 상태는 GameState에, 플레이어별 상태는 PlayerState에 둡니다.
| 클래스 | 담당 정보 | 네트워크와 수명 |
|---|---|---|
| GameMode | 게임 규칙, 기본 클래스 선택, 스폰·매치 흐름 | 서버의 인스턴스에서 판단합니다. 원격 클라이언트에 이 인스턴스를 복제하지 않습니다. |
| GameState | 라운드·남은 시간·팀 점수 등 게임 전체 상태 | 서버가 변경한 공유 상태를 클라이언트에 전달합니다. 사용자 정의 변수의 복제 설정이 필요합니다. |
| PlayerState | 플레이어 이름·점수·팀 등 플레이어별 상태 | 서버의 플레이어별 상태를 복제합니다. 사용자 정의 변수의 복제 설정을 확인합니다. Pawn과 분리해 재스폰 때 유지할 수 있으며, 영구 저장은 별도 설계입니다. |
- 클래스: GameMode
- 담당 정보: 게임 규칙, 기본 클래스 선택, 스폰·매치 흐름
- 네트워크와 수명: 서버의 인스턴스에서 판단합니다. 원격 클라이언트에 이 인스턴스를 복제하지 않습니다.
- 클래스: GameState
- 담당 정보: 라운드·남은 시간·팀 점수 등 게임 전체 상태
- 네트워크와 수명: 서버가 변경한 공유 상태를 클라이언트에 전달합니다. 사용자 정의 변수의 복제 설정이 필요합니다.
- 클래스: PlayerState
- 담당 정보: 플레이어 이름·점수·팀 등 플레이어별 상태
- 네트워크와 수명: 서버의 플레이어별 상태를 복제합니다. 사용자 정의 변수의 복제 설정을 확인합니다. Pawn과 분리해 재스폰 때 유지할 수 있으며, 영구 저장은 별도 설계입니다.
수명과 영구 저장을 구분합니다
Standalone도 자체 서버 역할로 GameMode를 사용합니다. GameModeBase–GameStateBase 또는 GameMode–GameState의 계열을 맞춥니다. Pawn과 분리한 상태도 디스크 저장이나 모든 레벨 이동 때 자동 보존되는 것은 아닙니다.
위 그림은 세 클래스가 담당하는 정보와 네트워크에서의 위치를 한 표로 묶습니다. GameMode는 게임 규칙, 기본 클래스 선택, 스폰과 매치 흐름을 맡으며, 서버의 인스턴스에서 판단하고 원격 클라이언트에는 복제되지 않습니다. GameState는 라운드, 남은 시간, 팀 점수 같은 게임 전체의 상태를 맡아 서버가 바꾼 값을 클라이언트에 전달하고, PlayerState는 플레이어 이름, 점수, 팀 같은 플레이어별 상태를 복제합니다. 두 스테이트 모두 직접 만든 변수는 복제 설정이 필요하다는 점이 그림에 함께 적혀 있습니다.
그림 아래의 설명은 수명과 영구 저장을 구분하라고 짚습니다. Standalone 게임도 자체적으로 서버 역할을 하므로 GameMode를 쓰고, GameModeBase와 GameStateBase, GameMode와 GameState처럼 계열을 맞춰 씁니다. 그리고 Pawn과 분리한 상태라도 디스크에 저장하거나 모든 레벨 이동에서 저절로 보존되지는 않습니다.
세 클래스를 잇는 고리는 게임 모드입니다. 게임 모드가 어떤 게임 스테이트와 플레이어 스테이트 클래스를 쓸지 정하므로, 새 스테이트 클래스를 만들었다면 반드시 게임 모드의 Classes 섹션에서 연결해야 합니다. 이렇게 일을 나누면 멀티플레이어에서 무엇을 서버에서 판단하고 무엇을 클라이언트에 보낼지가 분명해지고, 싱글플레이어에서도 규칙과 상태가 어디 있는지 찾기 쉬워집니다.
점수 규칙을 세 클래스에 나눠 보기
팀 점수가 50점 이상이면 승리하는 게임을 생각해 봅니다. 현재 팀 점수는 30점이고, 어떤 플레이어의 개인 점수는 20점입니다. 이때 "50점 이상이면 승리"라는 판단은 서버의 GameMode가 하고, 현재 팀 점수 30은 GameState의 공유 변수에 두어 모든 클라이언트에 전달합니다. 개인 점수 20은 그 플레이어의 PlayerState에 둡니다.
이 나눔은 같은 숫자를 세 클래스에 각각 복사해 두라는 뜻이 아닙니다. 규칙은 GameMode에, 게임 전체의 값은 GameState에, 개인의 값은 PlayerState에 하나씩 두고, 필요한 곳에서 그 값을 읽습니다. 같은 값을 여러 곳에 두면 한쪽만 바뀌었을 때 어느 값이 맞는지 알 수 없게 됩니다.
정리
- 게임 모드는 게임의 규칙과 기본 클래스, 스폰, 게임 흐름을 맡으며 서버에만 존재합니다. Standalone 게임에서는 게임 자체가 서버 역할을 합니다.
- 게임 스테이트는 라운드, 남은 시간, 팀 점수처럼 모두가 보는 게임 전체의 상태를, 플레이어 스테이트는 이름, 점수, 팀처럼 플레이어별 상태를 담아 클라이언트에 복제합니다.
- 두 스테이트에 직접 만든 변수는 따로 복제를 설정하고 서버에서 바꿔야 클라이언트에 전달됩니다.
GameModeBase와GameStateBase,GameMode와GameState를 짝지어 쓰고, 만든 스테이트 클래스는 게임 모드의Classes섹션에서 연결합니다.- 프로젝트 기본 게임 모드는
Maps & Modes에서, 레벨별 게임 모드는World Settings의GameMode Override에서 정하며 레벨 설정이 우선합니다. - 플레이어 스테이트는 폰을 바꿔도 유지되지만, 디스크 저장이나 재접속 뒤의 보존은 별도로 설계해야 합니다.
연습 문제
1. 클라이언트 UI가 읽을 상태
서버가 라운드를 2로 바꾸고 남은 시간을 90초로 갱신했습니다. 클라이언트의 UI가 Get Game Mode로 이 값들을 직접 읽으려 하면 왜 실패할 수 있을까요? 어디에 두고 어떻게 바꿔야 할까요?
해설 보기
원격 클라이언트에는 서버의 GameMode 인스턴스가 없기 때문입니다. 클라이언트에서 Get Game Mode를 호출하면 유효한 참조를 얻지 못하므로, 거기서 라운드와 남은 시간을 읽을 수 없습니다.
라운드와 남은 시간은 GameState에 변수로 만들고 복제를 설정한 뒤, 서버에서 값을 바꿉니다. 그러면 UI는 자기 클라이언트에 복제된 GameState에서 받은 현재 값을 표시합니다. 이때도 복제가 모든 화면에 동시에 도착한다고 가정하지 않습니다.
2. 폰을 교체했을 때 남는 점수
개인 점수가 20점인 플레이어가 쓰러져, 같은 세션 안에서 Pawn만 새 인스턴스로 다시 스폰됩니다. 플레이어 스테이트는 그대로 쓰입니다. 점수를 Pawn에 둔 경우와 PlayerState에 둔 경우, 재스폰 뒤 점수는 각각 어떻게 될까요?
해설 보기
Pawn에 둔 점수는 새 Pawn 인스턴스의 기본값으로 돌아갑니다. 쓰러진 Pawn과 함께 그 인스턴스의 변수도 사라지고, 새 Pawn은 처음 값으로 시작하기 때문입니다.
PlayerState에 둔 점수는 같은 PlayerState를 계속 쓰므로 20점이 유지됩니다. 다만 이 조건은 같은 세션 안의 재스폰이며, 게임을 껐다 켜거나 재접속했을 때의 보존까지 이 구조가 해 주지는 않습니다. 그런 보존이 필요하면 세이브 같은 영구 저장을 따로 설계합니다.
3. 반영되지 않는 기본 게임 모드
프로젝트 설정의 Default GameMode를 GM_MyGame으로 바꿨는데, 한 레벨에서만 여전히 다른 게임 모드가 쓰입니다. 무엇을 먼저 확인해야 할까요? 또 GM_MyGame이 쓰이는 레벨에서 GS_MyGame의 변수가 기본값만 보인다면 무엇을 확인할까요?
해설 보기
먼저 그 레벨의 World Settings에서 GameMode Override가 지정되어 있는지 봅니다. 레벨별 지정은 프로젝트 기본값보다 우선하므로, 여기에 다른 게임 모드가 있으면 프로젝트 설정을 바꿔도 그 레벨에는 반영되지 않습니다.
두 번째 경우에는 GM_MyGame의 Class Defaults에서 Game State Class가 GS_MyGame으로 지정되어 있는지 확인합니다. 지정되지 않았다면 엔진의 기본 게임 스테이트가 만들어지므로 GS_MyGame의 로직이 실행되지 않습니다. GM_MyGame이 GameMode 계열이라면 게임 스테이트도 GameState 계열인지 함께 확인합니다.
게임 모드와 게임 스테이트의 역할, 게임 모드를 지정하는 방법의 우선순위는 언리얼 엔진 공식 문서의 게임 모드와 게임 스테이트에서 확인할 수 있습니다.
다음 글 플레이어 컨트롤러, 폰 블루프린트에서는 플레이어의 입력을 받는 플레이어 컨트롤러와, 월드에서 실제로 움직이는 폰의 역할을 나누어 살펴봅니다.