| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- BOJ
- 독서
- team project
- git
- C++
- multi-thread
- Unity
- PS
- c#
- Network Programming
- System Programming
- docker
- Photon
- Online Judge
- design pattern
- Data Structure
- Toy Project
- Today
- Total
I'm FanJae.
Unity 게임 개발 캠프 개인 프로젝트 22일차(Server). 서버 기반 발판 이동 시뮬레이션 구현 본문
Unity 게임 개발 캠프 개인 프로젝트 22일차(Server). 서버 기반 발판 이동 시뮬레이션 구현
FanJae 2026. 10. 5. 23:131. 시작에 앞서
- 전날에는 Client가 요청한 좌표를 Server에서 검증하고, Map 범위와 이동 속도를 기준으로 최종 위치를 확정하도록 이동 구조를 변경했다.
- 하지만 횡스크롤 형태의 이동에서는 단순히 요청 좌표가 정상적인지 검사하는 것만으로는 부족하다.
- Player가 발판 위에 있는지, 점프 중인지, 중력이 적용되고 있는지, 벽이나 천장과 충돌했는지 등을 Server가 직접 판단할 수 있어야 한다.
- 또한 Client가 최종 위치를 계산해서 보내는 구조를 계속 사용한다면 물리 계산의 주도권이 여전히 Client에 남게 된다.
- 따라서 이번 작업에서는 Client가 좌표 대신 이동 입력만 전송하고, Server가 일정한 Tick마다 Player의 이동 상태를 직접 계산하는 구조를 구현했다.
2. Map별 이동 방식을 구분
- 모든 Map을 바로 발판 이동으로 변경하지 않고 Map별로 이동 방식을 구분하도록 했다.
enum class MovementMode : uint8_t
{
Free = 0,
Platformer = 1
};
- 기존 자유 이동 방식은 Free로 유지하고, 발판과 중력을 사용하는 Map은 Platformer로 구분했다.
Map 100000000
↓
Platformer
Map 100000001
↓
Free
- 이를 통해 기존 기능을 유지하면서 특정 Map부터 새로운 이동 시스템을 적용할 수 있도록 했다.
3. 발판 이동 설정을 데이터로 분리
- 발판 이동에 필요한 설정 역시 Server 코드에 직접 작성하지 않고 CSV 데이터로 분리했다.
map_movement.csv
footholds.csv
colliders.csv
- map_movement.csv에서는 다음과 같은 값을 관리한다.
Movement Mode
Player Half Width / Height
Horizontal Speed
Jump Speed
Gravity
Max Fall Speed
Spawn Foothold
- 실제 테스트 Map은 다음과 같이 설정했다.
Horizontal Speed = 80
Jump Speed = 240
Gravity = 480
Max Fall Speed = 320
- Player 충돌 크기 역시 중심 좌표만 사용하는 것이 아니라 반폭과 반높이를 별도로 관리하도록 했다.
4. Foothold와 Collider 데이터 구성
- 발판은 FootholdDefinition으로 관리한다.
struct FootholdDefinition
{
uint32_t footholdId;
int32_t x1;
int32_t y1;
int32_t x2;
int32_t y2;
uint32_t prevId;
uint32_t nextId;
};
- 현재 단계에서는 수평 발판만 사용한다.
Foothold 1
───────────────
│
│ 연결
▼
───────────────
Foothold 2
- 서로 연결된 발판은 prevId, nextId를 통해 이어지도록 했다.
- 벽과 천장은 발판과 별도로 사각형 Collider로 관리한다.
struct MapCollisionDefinition
{
uint32_t colliderId;
int32_t minX;
int32_t minY;
int32_t maxX;
int32_t maxY;
};
- 일방향 발판과 모든 방향을 막는 벽의 역할을 분리한 것이다.
5. 지형 데이터 검증
- 발판과 Collider는 Server 물리 계산의 기준이 되기 때문에 잘못된 데이터로 Server가 실행되지 않도록 했다.
- 예를 들어 다음과 같은 경우를 검사한다.
중복 ID
존재하지 않는 Map
잘못된 발판 연결
겹쳐 있는 발판
Map 밖의 좌표
Spawn과 발판 높이 불일치
Spawn과 Collider 겹침
잘못된 이동 속도 / 중력 값
- 발판 연결 역시 단순히 ID가 존재하는지만 보는 것이 아니라 양쪽 참조와 실제 끝점 좌표가 맞는지 확인한다.
Foothold A.next = B
Foothold B.prev = A
A.x2 == B.x1
A.y2 == B.y1
- 전체 CSV를 임시 데이터에 먼저 로딩한 뒤 모든 검증이 성공했을 때만 실제 Map에 적용하도록 했다.
- 따라서 일부 파일만 정상적으로 읽힌 상태로 Server가 실행되지 않도록 했다.
6. 서버 고정 Tick 추가
- 기존 GameServer의 동작은 Network Event 처리가 중심이었다.
- 하지만 발판 이동은 Packet이 들어올 때만 움직이면 안 된다.
- 점프 이후에는 Client가 아무 입력을 보내지 않더라도 중력에 의해 계속 낙하해야 하기 때문이다.
- 따라서 MapManager에서 일정한 간격으로 Map을 갱신하도록 했다.
_nextTick =
now + std::chrono::milliseconds(20);
- 물리 Tick은 20ms, 즉 약 50Hz로 동작한다.
Server Time
0ms
│
20ms → Tick
│
40ms → Tick
│
60ms → Tick
│
80ms → Tick
- 각 Tick마다 모든 Map의 Tick()을 호출한다.
for (const auto& entry : _maps)
{
entry.second->Tick(_tick,_nextTick);
}
7. IOCP 대기 시간과 Map Tick 연결
- 기존에는 Network Event를 기다리기 위해 일정한 Timeout으로 IOCP Dispatch를 수행했다.
- 하지만 20ms Server Tick을 사용하려면 다음 Tick보다 오래 대기해서는 안 된다.
- 따라서 다음 Tick까지 남은 시간을 IOCP 대기 시간으로 사용하도록 변경했다.
현재 시간
│
▼
다음 Map Tick까지 남은 시간 계산
│
▼
IOCP Wait Timeout
- Network Packet이 없어도 Tick 시간이 되면 Server가 깨어나 물리를 처리한다.
- 반대로 Packet이 먼저 들어오면 Network Event를 처리한 뒤 경과한 Tick도 계산한다.
8. Tick 지연에 대한 Catch-up 제한
- Server가 일시적으로 지연될 경우 여러 Tick이 밀릴 수 있다.
- 밀린 Tick을 제한 없이 한 번에 실행하면 한 순간에 지나치게 많은 물리 연산이 몰릴 수 있다.
- 따라서 한 번에 따라잡을 수 있는 Tick 수를 최대 5회로 제한했다.
while (now >= _nextTick &&
count < 5)
{
...
}
- 한도를 넘어선 경우에는 오래된 시간을 모두 재생하지 않고 다음 Tick 기준을 현재 시간 이후로 다시 맞춘다.
정상
Tick → Tick → Tick
긴 지연
Tick
Tick
Tick
Tick
Tick
──── 최대 5회
나머지 오래된 Tick은 버리고
현재 시점부터 재시작
- Server 지연 때문에 과거 입력을 지나치게 오래 재생하지 않도록 한 것이다.
9. Client는 좌표가 아니라 Input만 전달
- Platformer Map에서는 기존 MoveRequest(x, y)를 더 이상 이동 방식으로 사용하지 않는다.
- 대신 MovementInputPacket을 추가했다.
struct MovementInputPacket
{
uint32_t mapId;
uint64_t generation;
uint64_t sequence;
int8_t horizontal;
uint8_t jumpHeld;
};
- Client가 전달하는 것은 다음 정도다.
Horizontal
-1 : Left
0 : Stop
1 : Right
JumpHeld
true / false
- Player의 최종 X, Y 좌표는 Client가 전달하지 않는다.
10. 잘못된 이동 Input 검증
- Server에서는 이동 입력을 바로 적용하지 않고 현재 Player 상태와 비교한다.
if (input.generation != _generation || input.sequence <= _inputSequence || input.horizontal < -1 ||
input.horizontal > 1 || input.jumpHeld > 1)
{
return false;
}
- 주요 검증 기준은 다음과 같다.
현재 Map Generation인가?
이전보다 큰 Sequence인가?
Horizontal이 -1 ~ 1인가?
JumpHeld가 0 또는 1인가?
- 잘못된 Input이 들어오면 해당 이동을 적용하지 않고 현재 Server 상태를 InputRejected 상태로 다시 전달한다.
11. 같은 Map 재입장을 Generation으로 구분
- Player가 Map에 들어갈 때마다 Generation을 증가시킨다.
void Player::BeginMap()
{
++_generation;
...
}
- 같은 Map에 다시 들어오더라도 이전 입장과 새로운 입장을 구분할 수 있다.
Map 100000000
Generation 3
↓ Change Map
↓ Return
Map 100000000
Generation 4
- 이전 Generation에서 늦게 도착한 이동 입력은 적용하지 않는다.
- 단순히 Map ID만 비교하는 것보다 Map 입장 단위까지 구분하게 된 것이다.
12. 짧은 Jump 입력 보존
- Network Packet과 Server Tick의 타이밍이 항상 일치하는 것은 아니다.
- 예를 들어 다음 두 Packet이 같은 Tick 전에 들어올 수 있다.
Jump = true
Jump = false
- 현재 상태만 저장하면 Tick 시점에는 이미 false가 되어 짧은 Jump 입력이 사라질 수 있다.
- 따라서 Jump가 눌린 순간을 별도의 Pending 상태로 저장한다.
if (input.jumpHeld != 0 &&
!_input.jumpHeld)
{
_jumpPending = true;
}
- 다음 Tick에서 한 번은 반드시 Jump 입력으로 처리한 뒤 Pending 값을 제거한다.
Press
│
Release
│
▼
_jumpPending = true
│
▼
Next Physics Tick
│
▼
Jump 적용
13. 오래된 입력 만료 처리
- Client가 이동 중 연결 문제로 Input Packet을 더 이상 보내지 않는다면 Server가 마지막 입력을 계속 유지해서는 안 된다.
- 따라서 마지막 입력을 받은 시간도 기록한다.
_inputTime = std::chrono::steady_clock::now();
- 250ms 동안 새로운 입력이 없다면 현재 입력을 중립 상태로 변경한다.
expired = now - _inputTime >= std::chrono::milliseconds(250);
- 수평 이동은 멈추지만 공중에 있는 Player의 중력 Simulation은 계속 진행된다.
마지막 Input: Right
│
│ 250ms 동안 갱신 없음
▼
Horizontal = 0
│
├─ 지상 → 정지
│
└─ 공중 → 중력 계속 적용
14. Player 물리 상태를 Server가 직접 관리
- Platformer Player는 다음과 같은 상태를 Server에서 관리한다.
struct PlatformMovementState
{
double x;
double y;
double velocityX;
double velocityY;
uint32_t footholdId;
bool grounded;
bool jumpHeld;
};
- 좌표뿐 아니라 속도와 현재 발판, 접지 상태까지 Server의 게임 상태가 된 것이다.
- 따라서 Client가 결과를 알려주지 않는다.
- Server가 Map 지형과 이동 상태를 이용해 직접 결정한다.
15. 수평 이동과 발판 연결 처리
- Player가 발판 위에 있을 때는 현재 Foothold를 기준으로 수평 이동한다.
next.velocityX = input.horizontal * horizontalSpeed;
- 현재 발판의 끝을 넘어가더라도 연결된 다음 발판이 있다면 해당 발판으로 이동한다.
Foothold 1
───────────────┐
└──────────────
Foothold 2
- 연결된 발판이 더 이상 없다면 Grounded 상태를 해제한다.
state.grounded = false;
state.footholdId = 0;
- 이후부터 공중 이동으로 처리한다.
16. 점프와 중력
- 점프는 Grounded 상태에서 Jump가 새롭게 눌렸을 때만 시작한다.
if (next.grounded && jumpPressed)
{
next.grounded = false;
next.footholdId = 0;
next.velocityY = jumpSpeed;
}
- 공중에서는 매 Tick 중력을 적용한다.
state.velocityY = max(-maxFallSpeed,state.velocityY - gravity * seconds);
- 최대 낙하 속도를 두어 중력 누적으로 속도가 무한히 증가하지 않도록 했다.
17. 발판 착지 처리
- 발판 착지는 단순히 이동 후 최종 좌표가 발판보다 아래인지 확인하는 방식으로 처리하지 않았다.
- 한 Tick 동안 이동하는 선분과 발판이 실제로 교차하는 시점을 계산한다.
이전 위치
●
\
\
\
────────X────── 발판
\
● 예상 최종 위치
- 하강 중 발판 높이를 통과하는 시점의 X 좌표가 실제 발판 범위 안에 있는지 확인한다.
- 여러 발판을 한 Tick에 통과할 정도로 빠르게 떨어지더라도 처음 만나는 발판에 착지하도록 했다.
- 이를 통해 빠른 낙하로 얇은 발판을 통과하는 문제를 줄였다.
18. 벽 충돌 처리
- 벽은 사각형 Collider로 정의되어 있다.
- Player를 단순 점으로 보는 것이 아니라 Player의 반폭과 반높이를 Collider에 반영한다.
Player
┌─────┐
│ │ →→→
└─────┘
█████
█████
█████
- 이동 시작점과 이동량을 이용해 Collider와 가장 먼저 충돌하는 시간을 계산한다.
- 벽에 부딪히면 수평 속도를 0으로 만든다.
if (collision.blockX)
{
state.velocityX = 0.0;
}
- 천장에 부딪힌 경우에는 수직 속도를 막는다.
- 또한 벽에 붙어 있는 상태에서도 반대 방향으로 움직이거나 아래로 떨어지는 것은 가능하도록 처리했다.
19. Map 경계와 Respawn
- 좌우와 위쪽 경계는 Player 충돌 영역 전체가 Map 내부에 있도록 제한한다.
- 반면 아래쪽 경계를 벗어나면 단순히 Y를 경계값에 고정하지 않고 Spawn 위치로 복귀시킨다.
Player Fall
│
▼
Map Bottom
│
▼
Respawn
│
▼
Spawn Foothold
- 이 경우 Server 상태에는 Respawned 이유를 포함시켜 Client가 일반 이동과 구분할 수 있도록 했다.
20. Map Tick에서 실제 Player 상태 계산
- Server의 Map::Tick()에서 각 Player의 현재 입력을 가져와 이동 Simulation을 수행한다.
auto input = player.ConsumeInput(now,expired);
SimulationResult result = _simulation->Step(state,input);
- 이후 Simulation 결과를 Server의 Player 위치에 반영한다.
player.SetPosition(round(state.x),round(state.y));
- 즉 실제 Player 위치 변화는 Network Request Handler가 아니라 Map Tick에서 발생한다.
MovementInput
│
▼
Player Input State 저장
│
▼
20ms Map Tick
│
▼
MovementSimulation
│
▼
Server Player State 갱신
21. MovementState 전파
- Server가 계산한 결과는 MovementState 패킷으로 전달한다.
struct MovementStatePacket
{
uint32_t mapId;
uint64_t generation;
uint32_t characterId;
uint64_t serverTick;
uint64_t sequence;
int64_t x;
int64_t y;
int64_t velocityX;
int64_t velocityY;
uint32_t footholdId;
uint8_t grounded;
MovementStateReason reason;
};
- 단순 X, Y만 전달하는 것이 아니라 이동 Simulation에 필요한 상태를 함께 전달한다.
Position
Velocity
Foothold
Grounded
Server Tick
Applied Input Sequence
- 이를 통해 Client가 Server의 현재 이동 상태를 기준으로 자신의 예측을 보정할 수 있다.
22. 이동 상태 전파 주기
- 물리 Simulation 자체는 20ms마다 실행하지만 모든 Tick마다 무조건 Packet을 보내는 것은 아니다.
- 일반 상태에서는 3 Tick마다 한 번 정도 상태를 전송한다.
if (tick % 3 != 0 && grounded == state.grounded && reason == MovementStateReason::Normal)
{
continue;
}
- 약 60ms 주기로 상태를 전달하면서도 다음과 같은 중요한 변화가 발생하면 즉시 전송한다.
Grounded 상태 변경
Respawn
Input Expired
- Network Packet 수와 상태 반영 속도 사이에서 기본적인 균형을 잡은 것이다.
23. 같은 Map의 모든 Player에게 상태 전파
- MovementState는 이동한 본인뿐 아니라 같은 Map의 다른 Player에게도 전달한다.
Player A Input
│
▼
Server Simulation
│
▼
Player A MovementState
│
├────────► Player A
│
├────────► Player B
│
└────────► Player C
- 따라서 모든 Client가 같은 Server 상태를 기준으로 Player를 표시할 수 있다.
24. 지형 데이터도 Server가 전달
- Client가 Server와 같은 방식으로 이동을 예측하려면 동일한 지형 데이터가 필요하다.
- 따라서 Map 입장 시 Server에서 다음 순서로 지형 정보를 전달한다.
MapInfo
│
▼
MapGeometry
│
├─ Foothold
├─ Foothold
├─ Collider
│
▼
GeometryEnd
- 발판 전체를 하나의 거대한 Packet으로 만들지 않고 객체별 Packet으로 나눠 전송한다.
- 현재 최대 Packet 크기를 넘지 않도록 하기 위한 방식이다.
25. Platformer Map에서 기존 MoveRequest 차단
- 새 이동 방식이 생겼더라도 기존 절대 좌표 요청을 그대로 허용하면 발판 Simulation을 우회할 수 있다.
- 따라서 Platformer Map에서는 기존 좌표 기반 이동을 거절한다.
if (IsPlatformer())
return MoveResult::WrongMovementMode;
- 반대로 Free Map에서는 기존 이동 방식을 그대로 사용한다.
Platformer
→ MovementInput
Free
→ MoveRequest
26. Protocol Version 추가
- 이번 작업으로 GameServer와 Client 사이의 Packet 구조가 크게 변경됐다.
- 이전 Client가 새로운 Server에 접속하면 같은 Opcode를 다른 구조로 해석할 위험이 있다.
- 따라서 Game 입장 요청에 Protocol Version을 추가했다.
constexpr uint32_t
GAME_PROTOCOL_VERSION = 2;
struct EnterGameRequest
{
uint64_t authKey;
uint32_t protocolVersion;
};
- Version이 다르면 게임 입장을 명시적으로 거절한다.
Old Client
Protocol 1
│
▼
GameServer Protocol 2
│
▼
ProtocolMismatch
- 호환되지 않는 Client가 잘못된 상태로 게임에 들어가는 것을 막도록 했다.
27. 테스트
- 발판 이동은 별도의 테스트 프로젝트를 만들어 Network와 분리된 상태에서도 검증할 수 있도록 했다.
- 주요 테스트 항목은 다음과 같다.
발판 데이터 로딩
잘못된 발판 연결
Spawn 충돌
중복 Collider
지상 이동
연결 발판 통과
점프
중력
착지
빠른 낙하
벽 충돌
천장 충돌
모서리 충돌
벽을 따라 낙하
Map 경계
낙하 Respawn
잘못된 Input
잘못된 State
동일 입력의 결정성
- 특히 동일한 초기 상태와 입력을 두 Simulation에 적용했을 때 같은 결과가 나오는지도 확인했다.
- Client C# Prediction과 비교하기 위한 C++ Physics Trace 생성 기능도 추가했다.
28. 정리
- 이번 작업에서는 기존 좌표 요청 기반 이동에서 벗어나 Server가 Player의 입력을 받아 직접 물리 상태를 계산하는 발판 이동 구조를 구현했다.
- Map별로 Free와 Platformer 이동 방식을 구분하고, 발판·Collider·점프·중력·Player 충돌 크기를 CSV 데이터로 분리했다.
- Server 시작 시 모든 지형 데이터를 검증하고, 잘못된 발판 연결이나 Spawn 충돌 등이 있으면 Server 실행 자체를 중단하도록 했다.
- Platformer Map은 20ms 고정 Tick으로 갱신하며 Player의 좌우 입력과 Jump 상태를 바탕으로 이동, 점프, 중력, 착지, 벽 및 천장 충돌을 계산한다.
- Client는 최종 좌표가 아니라 MovementInput만 전달하며, Map Generation과 Sequence를 검사하여 오래되거나 잘못된 입력이 현재 Player 상태에 적용되지 않도록 했다.
- 일정 시간 동안 새로운 입력이 들어오지 않으면 마지막 입력을 만료시켜 계속 이동하는 상황도 방지했다.
- Server가 계산한 위치, 속도, 현재 발판, 접지 여부와 Server Tick은 MovementState를 통해 같은 Map의 Client들에게 전달하도록 했다.
- 또한 Platformer Map에서는 기존 절대 좌표 이동을 차단하고, Protocol Version을 추가해 이전 Client와 새로운 Server의 패킷 구조가 섞이지 않도록 했다.
- Player 이동의 최종 판단뿐 아니라 실제 이동 Simulation 자체를 Server가 담당하는 구조로 확장했다.
'Projects > MyToyMapleServer' 카테고리의 다른 글
| Unity 게임 개발 캠프 개인 프로젝트 23일차(Server). (0) | 2026.10.06 |
|---|---|
| Unity 게임 개발 캠프 개인 프로젝트 22일차(Client). 서버 지형 기반 발판 이동 예측 구현 (0) | 2026.10.06 |
| Unity 게임 개발 캠프 개인 프로젝트 21일차(Client). 서버 확정 위치 기반 클라이언트 이동 보정 (0) | 2026.10.05 |
| Unity 게임 개발 캠프 개인 프로젝트 21일차(Server). 서버 기준 Player 이동 검증 구현 (0) | 2026.10.04 |
| Unity 게임 개발 캠프 개인 프로젝트 20일차. 테스트 클라이언트 책임 분리 및 Map-local 채팅 구현 (0) | 2026.10.03 |