본문 바로가기

- Programming/- C#

★ 8. c# 네트워크 개발 p7

반응형

==================================================================

모든 출처는

- 유니티 개발자를 위한 C#으로 온라인 게임 서버 만들기 - 저자 이석현, 출판사 한빛미디어

그리고 URL :

http://lab.gamecodi.com/board/zboard.php?id=GAMECODILAB_Lecture_series&page=1&sn1=&divpage=1&sn=off&ss=on&sc=on&select_arrange=hit&desc=asc&no=68

==================================================================


C#으로 게임 서버 만들기 - 7. 온라인 세균전 게임 만들기

(초기 설계, 기능 구현)


3-3. 실시간 네트워크 게임 개발 (온라인 세균전 게임 만들기)

지금까지 만들면서 배워왔던 기술들을 모두 종합하여 온라인 세균전 게임을 개발해보도록 하겠습니다.
이번 장에서는 아래 내용들을 다룹니다.

- 세균전 게임의 초기 설계
- 게임 방의 골격
- 각 기능들의 상세 구현

-- (이번 강좌의 내용)

- 유저의 접속과 매칭
- 플레이어들이 같은 공간에 있도록 만들기
- 클라이언트와의 연동
- 안드로이드 apk 빌드

세균전 게임의 초기 설계
※ 이번 강좌에 나오는 내용들은 모두 서버측 코드입니다.

세균전 게임의 룰은 다음과 같습니다.

경기는 8*8로 이루어진 사각형의 보드 판에서 이루어집니다.
승리 조건은 보드판을 다 채울 때까지 상대방보다 더 많은 수의 세균을 번식하는 것입니다.
현재 자신의 위치로부터 한 칸은 복제, 두 칸은 이동입니다.
복제나 이동을 끝마친 후 주위 8칸 이내에 상대방의 세균이 존재한다면
자신의 세균으로 전염시킬 수 있습니다.

게임의 룰을 처리하는 코드는 서버에서 처리될 것이며 클라이언트는 유저의 입력을 받아들이고 화면에 그래픽을 출력하는 역할만 담당할 것입니다. 대부분의 온라인 게임은 클라이언트의 해킹을 방지하기 위해 서버에서 핵심 로직을 처리합니다. 클라이언트 소스코드에 로직이 존재할 경우 암호화나 난독화 등을 거친다 하더라도 악의적인 유저의 조작에서 완전히 벗어날 수 없기 때문입니다.

코딩하기 전에 게임이 어떤 식으로 진행될 것인지 흐름을 정리해 보는 시간이 필요합니다.
각 부분별 작업 예상 시간과 필요한 리소스들을 이 시점에서 잘 파악하는 것이 중요합니다.

게임 시작 -> 로고 화면 출력 -> 서버에 접속 -> 메인 화면 ->
대전 신청 -> 유저 매칭 -> 게임 진행 -> 게임 결과 -> 다시 메인 화면으로 복귀

전체적으로 위와 같은 흐름으로 이루어지게 되며 여기서 게임 진행 부분을 더 세분화 해보겠습니다.

맵 초기화 -> 1P 시작 -> 턴 종료 -> 2P 시작 -> 턴 종료 -> 게임 결과 체크 -> 다시 1P 시작

상용 게임이 아니라 예제 수준이므로 비교저거 단순하게 세워봤습니다.
실무에서는 이보다 더 세분화되고 상세한 작업 계획을 세운 뒤 개발에 들어가게 됩니다.

게임 방의 골격
게임 방이 어떻게 구성되는지 커다란 골격을 잡아볼 것입니다. 여기에 앞서 유저가 어떻게 접속하고 매칭은 어떻게 이루어지는지
궁금하시겠지만 일단 게임의 로직 처리부분부터 들어가보도록 하겠습니다.

지금부터 작성되는 코드는 모두 서버 쪽에서 수행되는 코드입니다. 서버 쪽에 로직이 모여 있으니 일단 서버 쪽부터 설명한 뒤
클라이언트를 연동하는 방식으로 진행하도록 하겠습니다.

1
2
3
4
5
6
/// <summary>
/// 게임의 로직이 처리되는 핵심 클래스이다.
/// </summary>
class CGameRoom
{
}
cs

아직은 아무 내용도 없지만 이 클래스에서 게임의 핵심 로직이 모두 처리될 것입니다.

로딩 완료 요청
클라이언트에서는 게임 리소스들을 불러 들여 화면에 표시해 준 뒤 서버에 로딩 완료 요청을 보냅니다.

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
/// <summary>
/// 클라이언트에서 로딩을 완료한 후 요청함.
/// 이 요청이 들어오면 게임을 시작해도 좋다는 뜻이다.
/// </summary>
/// <param name="sender"></param>
public void loading_complete(CGameUser sender)
{
    // 모든 유저가 준비 상태인지 체크한다.
 
    // 아직 준비가 안된 유저가 있다면 대기한다.
 
    // 모두 준비 되었다면 게임을 시작한다.
    battle_start();
}
 
/// <summary>
/// 게임을 시작한다.
/// </summary>
void battle_start()
{
    // 게임을 새로 시작할 때마다 초기화해줘야 할 것들
    reset();
 
    // 1P부터 시작
}
cs

모든 클라이언트에게 로딩 완료 요청을 받으면 서버는 게임을 시작합니다. 화면에 보이는 부분은 모두 클라이언트의 역할이지만 실제로 게임을 진행하고 처리하는 부분은 서버에서 이루어집니다. 따라서 서버가 게임 상태르르 변경할 모든 권한을 갖고 있습니다.
클라이언트는 단지 무엇을 해달라고 요청을 보낼 뿐이며 이에 대한 응답으로 화면을 갱신하는 일만 합니다.

이동 요청
세균전 게임에서 클라이언트가 할 수 있는 행동은 '이동 요청' 단 한가지 입니다.
"1번 세균을 2번 위치로 이동해주세요"라고 서버에 요청하면 서버는 클라이언트의 요청이 정상적인 상태인지 확인한 뒤 실제 이동을 허락합니다.
이 과정에서 비정상적인 요청이라고 판단되면 이동을 처리하지 않고 해킹된 클라이언트로 간주하여 접속을 끊는다던지 턴을 넘긴다던지하는 결정을 내리게 됩니다.
클라이언트에서 보내 오는 요청에 대해서는 반드시 이런 유효성 검사를 거쳐야 해킹으로부터 안전할 수 있습니다. 만약 이러한 과정을 거치지 않는다면 임의로 조작된 클라이언트에 의해서 게임의 룰이 파괴되는 불상사를 겪게 될지도 모릅니다.
그럼 어떠한 확인 과정을 거쳐야 하는지 살펴봅시다.

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
32
33
34
35
36
/// <summary>
/// 클라이언트의 이동 요청
/// </summary>
/// <param name="sender">요청한 유저</param>
/// <param name="begin_pos">시작 위치</param>
/// <param name="target_pos">이동하고자 하는 위치</param>
public void moving_req(CGameUser sender, byte begin_pos, byte target_pos)
{
    // sender 차례인지 체크
    // 체크 이유 : 현재 자신의 차례가 아님에도 불구하고 이동 요청을 보내온다면 게임의 턴이 엉망이 되어버릴 것입니다.
 
    // begin_pos에 sender의 캐릭터가 존재하는지 체크
    // 체크 이유 : 없는 캐릭터를 이동하려고하면 당연히 안되겠죠?
 
    // target_pos가 이동 또는 복제 가능한 범위인지 체크
    // 체크 이유 : 이동할 수 없는 범위로는 갈 수 없도록 처리해야 합니다.
 
    // 모든 체크가 정상이라면 이동 처리한다.
 
    // 세균을 이동하여 로직 처리를 수행한다. 전염시킬 상대방 세균이 있다면 룰에 맞게 전염시킨다.
 
    // 최종 결과를 모든 클라이언트들에게 전송한다.
 
    // 턴을 종료한다.
    turn_end();
}
 
/// <summary>
/// 턴을 종료한다. 게임이 끝났는지 확인하는 과정을 수행한다.
/// </summary>
void turn_end()
{
    // 보드판 상태를 확인하여 게임이 끝났는지 검사한다.
 
    // 아직 게임이 끝나지 않았다면 다음 플레이어로 턴을 넘긴다.
}
cs

모든 검사가 완료되면 서버에서 이동 처리를 한 뒤 주의에 있는 상대방 세균을 전염시킵니다. 그리고 변경된 보드판의 상태를 모든 클라이언트들에게 전송합니다. 마지막으로 턴을 종료하여 다음 플레이어의 차례로 만들어줍니다. 보드판에 세균이 다 차서 더이상 움직일 공간이 없을 때까지 번갈아가며 게임을 진행합니다. 아직 각 내용들의 코드는 작성하지 않았지만 게임이 어떻게 진행될 것인지 큰 흐름을 파악해 봤습니다.
이제 조금 더 들어가서 각 내용들을 하나하나 구현해 보도록 하겠습니다.

3-3-2. 각 기능들의 상세 구현

CGameRoom 멤버 변수 구성
이제 각 기능들을 하나하나 코딩해보도록 하겠습니다.
CGameRoom의 멤버 변수들은 이렇게 구성됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class CGameRoom
{
    enum PLAYER_STATE : byte
    {
        // 방에 막 입장한 상태
        ENTERED_ROOM,
 
        // 로딩을 완료한 상태
        LOADING_COMPLETE
    }
 
    // 게임을 진행하는 플레이어, 1P, 2P가 존재한다.
    List<CPlayer> players;
 
    // 플레이어들의 상태를 관리하는 변수
    Dictionary<byte, PLAYER_STATE> player_state;
 
    // 현재 턴을 짛냉하고 있는 플레이어의 인덱스
    byte current_trun_player;
 
    ...
}
cs

PLAYER_STATE는 플레이어들의 각 상태를 나타내는 enum 값입니다. 아직은 두 가지 상태 밖에 없지만 로직을 작성하면서 점점 늘어나게 될 것입니다. 상태가 과도하게 늘어나게 되면 디버깅 할 때 귀찮아질 수 있으니 꼭 필요한 상태만 정의해 두어야 합니다.

List<CPlayer> players;
플레이어를 구성하는 클래스입니다. 실제 클라이언트는 CGameUser 클래스와 매칭되지만
게임 플레이를 위해서는 CGameUser 클래스를 직접 사용하기보다는 플레이 전용으로 사용할 수 있는 CPlayer 클래스를 별도로 만드는 것이 좋습니다. 왜냐하면 CGameUser 클래스에는 유저의 계정 정보, 소켓 핸들 등의 정보가 들어 있는데 이런 정보들은 플레이 할 때 필요 없는 것들이기 때문입니다. 사용하지 않아도 되는 정보들은 숨겨 놓고 아예 접근하지 않도록 하는 것이 더 바람직한 코딩 습관입니다.

그렇다고 아예 모든 정보를 차단 시켜버리면 곤란해지므로 CGameUser와 CPlayer간에 최소한의 연결 고리는 만들어주어야 합니다.
CGameUser와 CPlayer는 1:1 관계로 이어지며 클라이언트에게 패킷을 전송하는데 CGameUser 클래스가 꼭 필요하므로 CPlayer의 생성자에서
이를 넘겨 받아 보관해 놓도록 합시다. 대신 CGameUser 클래스에서는 오로지 CPlayer 클래스에만 접근하여 처리하도록 규칙을 정해놓겠습니다.
다음과 같은 구성이 됩니다.

[CGameRoom ---- CPlayer ---- CGameUser]
CGameRoom은 CGameUser의 존재를 알지 못합니다. 오직 CPlayer에만 접근할 수 있습니다.

CPlayer 클래스를 살펴보겠습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class CPlayer
{
    CGameUser owner;
    public byte player_index { get; private set; }
 
    public CPlayer(CGameUser user, byte player_index)
    {
        this.owner = user;
        this.player_index = player_index;
    }
 
    public void send(CPacket msg)
    {
        this.owner.send(msg);
    }
}
cs

생성자에서 CGameUser와 플레이어 인덱스를 넘겨 받아 멤버 변수로 저장해 놓는 코드가 보입니다.
패킷을 전달할 때 CGameUser 클래스의 인스턴스를 참조하여 처리하기 위함입니다.
이 클래스는 private로 선언하여 외부에서는 CGameUser를 절대 건드리지 못하도록 하였습니다.

그 외 플레이어의 상태를 관리하는 변수와 현재 턴을 관리하는 변수들이 선언되어 있습니다.

게임 입장 처리와 로딩 동기화 하기

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
public CGameRoom()
{
    this.players = new List<CPlayer>();
    this.player_state = new Dictionary<byte, PLAYER_STATE>();
    this.current_turn_player = 0;
}
 
/// <summary>
/// 매칭이 성사된 플레이어들이 게임에 입장한다.
/// </summary>
/// <param name="user1"></param>
/// <param name="user2"></param>
public void enter_gameroom(CGameUser user1, CGameUser user2)
{
    // 플레이어들을 생성하고 각각 1번, 2번 인덱스를 부여해준다.
    CPlayer player1 = new CPlayer(user1, 1);        // 1P
    CPlayer player2 = new CPlayer(user2, 2);        // 2P
    this.players.Clear();
    this.players.Add(player1);
    this.players.Add(player2);
 
    // 플레이어들의 초기 상태를 지정해준다.
    this.player_state.Clear();
    change_playerstate(player1, PLAYER_STATE.ENTERED_ROOM);
    change_playerstate(player2, PLAYER_STATE.ENTERED_ROOM);
 
    // 로딩 시작 메시지 전송.
    CPacket msg = CPacket.create((Int16)PROTOCOL.START_LOADING);
    broadcast(msg);
}
 
/// <summary>
/// 클라이언트에서 로딩을 완료한 후 요청함.
/// 이 요청이 들어오면 게임을 시작해도 좋다는 뜻이다.
/// </summary>
/// <param name="sender">요청한 유저</param>
public void loading_complete(CPlayer player)
{
    // 해당 플레이어를 로딩 완료 상태로 변경한다.
    change_playerstate(player, PLAYER_STATE.LOADING_COMPLETE);
 
    // 모든 유저가 준비 상태인지 체크한다.
    if (!allplayers_ready(PLAYER_STATE.LOADING_COMPLETE))
    {
        // 아직 준비가 안된 유저가 있다면 대기한다.
        return;
    }
 
    // 모두 준비 되었다면 게임을 시작한다.
    battle_start();
}
 
/// <summary>
/// 모든 유저들에게 메시지를 전송한다.
/// </summary>
/// <param name="msg"></param>
void broadcast(CPacket msg)
{
    this.players.ForEach(player => player.send(msg));
    CPacket.destroy(msg);
}
 
/// <summary>
/// 플레이어의 상태를 변경한다.
/// </summary>
/// <param name="player"></param>
/// <param name="state"></param>
void change_playerstate(CPlayer player, PLAYER_STATE state)
{
    if (this.player_state.ContainsKey(player.player_index))
    {
        this.player_state[player.player_index] = state;
    }
    else
    {
        this.player_state.Add(player.player_index, state);
    }
}
 
/// <summary>
/// 모든 플레이어가 특정 상태가 되었는지를 판단한다.
/// 모든 플레이어가 같은 상태에 있다면 true, 한명이라도 다른 상태에 있다면 false를 리턴한다.
/// </summary>
/// <param name="state"></param>
/// <returns></returns>
bool allplayers_ready(PLAYER_STATE state)
{
    foreach(KeyValuePair<byte, PLAYER_STATE> kvp in this.player_state)
    {
        if (kvp.Value != state)
        {
            return false;
        }
    }
 
    return true;
}
cs

enter_gameroom 메소드에서 게임에 입장하는 유저들을 넘겨 받습니다.
아직 이 메소드가 어디서 어떻게 호출되는지는 모릅니다. 일단 어디선가 호출해 준다고 가정하고
코딩을 진행합시다. 지금은 개별적인 모듈을 만들어 나가는 단계이기 때문에 다른 부분과 연결되는 작업은
아직 들어가지 않은 상태입니다.
각 플레이어들에게 고유한 인덱스를 부여하고 로딩을 시작하라는 메시지를 보냅니다.
이 때 broadcast라는 메소드를 사용하였는데 이것의 의미는 방안에 들어와 있는 모든 유저들에게 메시지를 전송하겠다는 뜻입니다.
로딩을 시작하라는 메시지는 모든 유저들에게 똑같이 전달되어야 하므로 broadcast를 사용한 것입니다.
특정 유저에게만 전달해야하는 메시지는 send라는 메소드를 별도로 만들어 사용할 것입니다.

loading_complete 메소드는 로딩을 완료한 클라이언트가 보내오는 메시지입니다.
이 메소드 역시 어디서 어떤 구조로 호출되는지 아직 알 수는 없지만 그렇다고 가정하고 작업하도록 합시다.
혼자 진행하는 게임에서는 로딩이 완료되면 바로 시작해도 상관 없지만 온라인 게임에서는 모든 유저들의 로딩이 완료되었는지
확인한 후 게임을 시작해야 합니다. 상대방이 어떤 사양의 PC나 모바일 기기를 사용하는지 알 수 없으므로 로딩 시간은 기기마다
제각각일 것입니다. 따라서 조금 시간이 걸리더라도 모든 유저들의 로딩이 완료된 후 게임을 시작하도록 코딩해야 합니다.
만약 이 룰을 지키지 않게 될 경우 한명은 게임을 시작하였지만 다른 한 명은 아직 로딩중일 수 있어 동기화가 깨지는 일이 발생하게 됩니다.

게임 시작과 초기화

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
/// <summary>
/// 게임을 시작한다.
/// </summary>
void battle_start()
{
    // 게임을 새로 시작할 때마다 초기화해줘야 할 것들
    reset_gamedata();
 
    // 게임 시작 메시지 전송
    CPacket msg = CPacket.create((short)PROTOCOL.GAME_START);
    // 플레이어들의 세균 위치 전송
    msg.push((byte)this.players.Count);
    this.players.ForEach(player =>
    {
        msg.push(player.player_index);  // 누구인지 구분하기 위한 플레이어 인덱스.
 
        // 플레이어가 소지한 세균들의 전체 개수
        byte cell_count = (byte)player.viruses.Count;
        msg.push(cell_count);
        // 플레이어의 세균들의 위치 정보
        player.viruses.ForEach(position => msg.push(position));
    });
    // 첫 턴을 진행할 플레이어 인덱스
    msg.push(this.current_turn_player);
    broadcast(msg);
}
cs

모든 클라이언트의 로딩이 완료되었으면 게임을 시작하는 battle_start 메소드를 호출합니다.
게임 데이터를 초기화 해준 뒤 클라이언트들에게 게임 시작 메시지를 전송해줍니다.
이 때 필요한 정보는 전체 플레이어의 수, 각 플레이어들의 인덱스 정보, 세균들의 위치 정보 등이 있습니다.
이 정보를 토대로 클라이언트에서는 보드 판 위에 플레이어들의 세균을 배치하게 됩니다.

게임 보드 판의 변경 권한은 서버만 갖고 있도록 약속하였기 때문에 세균들의 위치 정보나 플레이어 인덱스와 같은
중요한 내용들은 서버에서 결정한 뒤 클라이언트에게 일방적으로 통보하는 방식으로 작업이 이루어집니다.
따라서 클라이언트에서 임의로 세균들의 정보를 조작하여도 서버에는 절대로 반영되지 않기 때문에
클라이언트 해킹을 무력화 시킬 수 있는 것이죠.
앞으로도 게임의 핵심적인 내용을 코딩할 때는 무조건 서버에 존재하는 정보를 토대로 작업할 것입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/// <summary>
/// 게임 데이터를 초기화 한다.
/// 게임을 새로 시작할 때마다 초기화 해줘야 할 것들을 넣는다.
/// </summary>
void reset_gamedata()
{
    // 보드 판 데이터 초기화.
    for (int i = 0; i < this.gameboard.Count; ++i)
    {
        this.gameboard[i] = EMPTY_SLOT;
    }
    // 1번 플레이어의 세균은 왼쪽 위 (0, 0), 오른쪽 위 (0, 7) 두군데에 배치한다.
    put_virus(100);
    put_virus(107);
    // 2번 플레이어는 세균은 왼쪽 아래 (7, 0), 오른쪽 아래 (7, 7) 두군데에 배치한다.
    put_virus(270);
    put_virus(277);
 
    // 턴 초기화
    this.current_turn_player = 1;    // 1P부터 시작.
}
cs

게임 데이터의 초기화를 진행합니다.
보드 판 정보를 빈 공간을 뜻하는 0으로 채워 넣습니다. 그리고 각 플레이어들의 세균을 두 개씩 배치합니다.

이동 처리와 세균 감염
이 게임에서 제일 핵심적인 부분인 세균의 이동을 처리해보도록 하겠습니다.
로딩이 완료되고 서버로부터 게임 시작 메시지를 받으면 클라이언트에서 유저의 입력을 받아
그 내용을 서버로 전송합니다.
서버가 없이 클라이언트 단독으로 진행하는 게임은 서버의 허락을 받을 필요 없이
클라이언트 내에서 직접 이동 처리를 진행하지만 서버가 존재하는 게임은 서버의 허락을
요청하는 과정을 거치도록 작성해야 합니다.
다른 플레이어의 이동 뿐만 아니라 본인의 이동 역시 마찬가지입니다.
클라이언트에서 이동 요청이 들어왔을 때 수행되는 메소드의 코드입니다.

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/// <summary>
/// 클라이언트의 이동 요청
/// </summary>
/// <param name="sender">요청한 유저</param>
/// <param name="begin_pos">시작 위치</param>
/// <param name="target_pos">이동하고자 하는 위치</param>
public void moving_req(CPlayer sender, byte begin_pos, byte target_pos)
{
    // sender 차례인지 체크
    if (this.current_turn_player != sender.player_index)
    {
        // 현재 턴이 아닌 플레이어가 보낸 요청이라면 무시한다.
        // 이런 비정상적인 상황에서는 화면이나 파일로 로그를 남겨두는 것이 좋다.
        return;
    }
 
    // begin_pos에 sender의 캐릭터가 존재하는지 체크
    if (this.gameboard[begin_pos] != sender.player_index)
    {
        // 시작 위치에 해당 플레이어의 세균이 존재하지 않는다.
        return;
    }
 
    // 목적지는 0으로 설정된 빈 공간이어야 한다.
    // 다른 세균이 자리하고 있는 곳으로는 이동할 수 없다.
    if (this.gameboard[target_pos] != 0)
    {
        // 목적지에 다른 세균이 존재한다.
        return;
    }
 
    // target_pos가 이동 또는 복제 가능한 범위인지 체크
    short distance = CHelper.get_distance(begin_pos, target_pos);
    if (distance > 2)
    {
        // 2칸을 초과하는 거리는 이동할 수 없다.
        return;
    }
 
    if (distance <= 0)
    {
        // 자기 자신의 위치로는 이동할 수 없다.
        return;
    }
 
    // 모든 체크가 정상이라면 이동 처리한다.
    if (distance == 1)        // 이동 거리가 한 칸일 경우에는 복제를 수행한다.
    {
        put_virus(sender.player_index, target_pos);
    }
    else if (distance == 2)    // 이동 거리가 두 칸일 경우에는 이동을 수행한다.
    {
        // 이전 위치에 있는 세균은 삭제한다.
        remove_virus(sender.player_index, begin_pos);
 
        // 새로운 위치에 세균을 놓는다.
        put_virus(sender.player_index, target_pos);
    }
 
    // 목적지를 기준으로 주위에 존재하는 상대방 세균을 감염시켜 같은 편으로 만든다.
    CPlayer opponent = get_opponent_player();
    infect(target_pos, sender, opponet);
 
    // 최종 결과를 broadcast한다.
    CPacket msg = CPacket.create((short)PROTOCOL.PLAYER_MOVED);
    msg.push(sender.player_index);      // 누가
    msg.push(begin_pos);                // 어디서
    msg.push(target_pos);               // 어디로 이동 했는지
    broadcast(msg);
}
cs

begin_pos와 target_pos는 유저가 선택한 지점과 이동하려는 지점을 나타내는 값입니다.
이 값은 클라이언트에서 보내온 값이므로 무조건 적으로 신뢰하면 안됩니다.
항상 잘못된 값이 들어올 수 있다고 가정한 뒤 코딩을 해야 합니다.
또한 패킷 캡쳐툴 등을 이용해 자신의 차례가 아님에도 불구하고 패킷을 보내올 수 있으므로
서버에 저장되어 있는 현재 플레이어의 턴과 비교하여 자신의 턴이 아닌 플레이어가 보내온 패킷은
무시해 버리는 등의 조치가 필요합니다.
begin_pos와 target_pos에 대해서는 서버에 존재하는 보드 판 정보를 토대로
정상적으로 허용 가능한 범위의 이동인지에 대해서 체크합니다. 이동할 수 없는 위치까지
이동하겠다고 요청이 들어온 경우에는 해킹으로 간주하여 처리하지 말아야 합니다.

이 코드에서는 메소드를 리턴시키고 끝냈지만 제대로 만드려면 오류 로그를 찍고 뒷처리까지 말끔하게 끝내야합니다.
해당 플레이어를 패배 처리 한다던지, 아니면 입력을 무시하고 다시 받게 한다던지 하는 등의 과정을 말하는 것이죠.
그렇지 않으면 해킹을 시도하지 않은 상대방 플레이어는 무슨 일이 일어나는건지 알지 못한 채로 가만히 기다릴 수 밖에
없기 때문이죠.
그런 처리까지 다 적용하려면 코드가 너무 방대해지기 때문에 이정도까지만 작성하도록 하겠습니다.

모든 체크 사항이 정상적이라면 본격적인 이동을 진행합니다.
클라이언트에서는 화면에 보여지는 처리 위주로 코드가 구성되지만 서버에서는 데이터 위주로 코드가 구성 됩니다.
따라서 이동 처리라고 하여도 실제로 캐릭터가 이동하는 모습을 구현하는 것이 아니라 데이터의 변화만 생길 뿐입니다.
게임의 룰에 따라 한 칸을 이동할 경우에는 자기 자신을 복제하여 세균이 하나 더 생기게 되며,
두 칸일 경우에는 목적지로 이동하도록 처리해야합니다.

1
2
3
4
5
6
7
8
9
10
/// <summary>
/// 보드판에 플레이어의 세균을 배치한다.
/// </summary>
/// <param name="player_index"></param>
/// <param name="position"></param>
void put_virus(byte player_index, short position)
{
    this.gameboard[position] = player_index;
    get_player(player_index).add_cell(position);
}
cs

플레이어의 세균을 배치하는 put_virus 메소드입니다. 보드 판에 세균을 배치하면서
해당 플레이어 객체에도 추가해줍니다. 추후에 어느 플레이어가 몇 마리의 세균을 보유했는지 등을
계산할 때 좀 더 쉽게 하기 위해서 플레이어 객체에도 세균 정보를 추가하도록 한 것입니다.

이동을 할 경우에는 현재 위치의 세균을 삭제한 뒤 새로운 위치에 세균을 배치하게 됩니다.
배치된 세균을 삭제하는 메소드인 remove_virus의 코드입니다.

1
2
3
4
5
6
7
8
9
10
/// <summary>
/// 배치된 세균을 삭제한다.
/// </summary>
/// <param name="player_index"></param>
/// <param name="position"></param>
void remove_virus(byte player_index, short position)
{
    this.gameboard[position] = EMPTY_SLOT;
    get_player(player_index).remove_cell(position);
}
cs

이동이 완료된 후에는 상대방 세균을 감염 시킬 수 있습니다.
감염 대상은 최종 목적지에서 한 칸 반경에 있는 상대방 세균들입니다.
상대방 세균을 감염 시키는 infect 메소드의 코드입니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// <summary>
/// 상대방의 세균을 감염 시킨다.
/// </summary>
/// <param name="basis_cell"></param>
/// <param name="attacker"></param>
/// <param name="victim"></param>
public void infect(short basis_cell, CPlayer attacker, CPlayer victim)
{
    // 방어자의 세균 중에 기준 위치로부터 1칸 반경에 있는 세균들이 감염 대상이다.
    List<short> neighbors = CHelper.find_neighbor_cells(basis_cell, victim.viruses, 1);
    foreach (short position in neighbors)
    {
        // 방어자의 세균을 삭제한다.
        remove_virus(victim.player_index, position);
 
        // 공격자의 세균을 추가한다.
        put_virus(attacker.player_index, position);
    }
}
cs

find_neighbor_cells라는 메소드를 통해서 주위 한 칸 반경에 있는 상대방 세균들의 위치를 구합니다. 해당 위치에 존재하고 있던 방어자의 세균을 삭제하고 공격자의 세균으로 교체합니다. find_neighbor_cells 메소드는 세균들의 위치 계산에 도움을 주는 CHelper 클래스에 속해 있습니다.
이 클래스의 내용들은 이 장의 뒷 부분에서 모두 모아 설명해 드리도록 하겠습니다.

마지막으로 모든 클라이언트들에게 플레이어의 이동을 알려주는 패킷을 전송합니다.

1
2
3
4
5
6
// 최종 결과를 broadcast한다.
CPacket msg = CPacket.create((short)PROTOCOL.PLAYER_MOVED);
msg.push(sender.player_index);      // 누가
msg.push(begin_pos);                // 어디서
msg.push(target_pos);               // 어디로 이동 했는지
broadcast(msg);
cs

앞서 처리한 내용은 많지만 전송 내용은 세 가지면 충분합니다.
해당 플레이어의 인덱스, 시작 위치, 목적지 위치가 끝입니다.
나머지는 클라이언트에서 서버와 동일한 로직을 갖고 처리하도록 할 것입니다.
변경된 보드 판의 내용을 전부 전송해야 하지 않겠냐고 생각하실 수도 있지만
서버와 클라이언트가 동일한 로직을 갖고 입력되는 파라미터를 동일하게 맞춘다면
결과 또한 서로 같을 것이기 때문에 패킷 내용은 단순하게 구성하여도 문제 되지 않습니다.
만약 클라이언트에서 로직을 조작하여 서버와 다른 데이터를 갖게 된다고 하더라도
서버의 데이터가 변경되는 것은 아니기 때문에 아무 의미 없는 작업이 되겠죠.

이렇게 해서 세균의 이동과 감염 처리 까지 모두 완료되었습니다.
서버에서는 데이터의 변경만 처리하면 되기 때문에 화면 갱신 등을 신경 쓸 필요가 없습니다.
최종 결과를 만들고 이 정보들을 클라이언트에게 전송한 뒤 다시 무언가 요청이 올 때까지 기다리고 있으면 됩니다.
클라이언트는 자신이 요청한 내용에 대해서 응답을 받아 그 내용을 토대로 각종 연출을 첨가하여
화면 출력과 애니메이션 처리등을 수행한 뒤 서버에 모든 동작이 끝났다는 것을 알려주게 됩니다.

마치 서버와 클라이언트가 서로 대화하듯 처리가 이루어지는 것을 볼 수 있습니다.
클라이언트는 소심한 성격을 가진 것처럼 항상 서버에 물어본 뒤 처리하는 것이 좋습니다.
반대로 서버는 대범하게 잘못된 클라이언트의 요청에 대해서 무시하거나 훈계를 할 수도 있는 위치입니다.

하지만 만약 반응성이 중요한 액션 게임이라면 얘기가 달라집니다.
유저가 연속 콤보를 사용했는데 매 순간순간 서버에 물어보고 처리하느라 반응이 늦어진다면 오히려 독이 되겠지요.
그럴때는 일단 클라이언트에서 먼저 처리를 한 뒤 추후에 서버의 검증을 거치는 방식이 필요합니다.
또한 실시간 통신이 필요하지 않은 단순한 퍼즐 게임 같은 경우에도 게임 로직 처리는 클라이언트에서
단독으로 진행한 뒤 마지막에 점수 계산만 서버에 맡기는 식으로 구현하기도 합니다.


반응형

'- Programming > - C#' 카테고리의 다른 글

★ 10. c# 네트워크 개발 p9  (0) 2017.02.14
★ 9. c# 네트워크 개발 p8  (1) 2017.02.13
★ 7. c# 네트워크 개발 p6  (0) 2017.02.12
★ 6. c# 네트워크 개발 p5  (0) 2017.02.10
★ 5. c# 네트워크 개발 p4  (0) 2017.02.09