==================================================================
모든 출처는
- 유니티 개발자를 위한 C#으로 온라인 게임 서버 만들기 - 저자 이석현, 출판사 한빛미디어
그리고 URL :
http://lab.gamecodi.com/board/zboard.php?id=GAMECODILAB_Lecture_series&no=82
==================================================================
지난 시간에는 클라이언트측 로직과 유저들이 요청한 패킷이 게임 방으로 전달되는 과정에 대해서 알아봤습니다.
이번 시간에는 게임의 로직을 만들어보겠습니다.
최종적으로 게임의 모습을 완성시키는 과정이 될 것입니다.
유저의 요청 처리하기
유저의 요청을 처리하는 부분은 CGameUser 클래스의 process_user_operation 메소드에서 수행됩니다.
CUserToken 클래스를 통해서 들어온 메시지는 패킷으로 포장되어 CGameServer 클래스의 메시지 큐로 들어온다는 것을
지난 시간에 설명 드렸습니다. 이 메시지 큐에 들어있는 패킷들은 CGameServer 클래스의 로직 스레드에서 하나씩 빼내어
CGameUser의 process_user_operation 메소드로 전달됩니다. 따라서 process_user_operation 메소드는 하나의
스레드만 접근하게 되며 여기서부터 처리되는 내용들은 모두 스레드 동기화에 신경 쓸 필요 없이 작업하면 됩니다.
다음은 process_user_operation 메소드의 코드입니다.
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 | void IPeer.process_user_operation(CPacket msg) { PROTOCOL protocol = (PROTOCOL)msg.pop_protocol_id(); Console.WriteLine("protocol id " + protocol); switch (protocol) { case PROTOCOL.ENTER_GAME_ROOM_REQ: Program.game_main.matching_req(this); break; case PROTOCOL.LOADING_COMPLETED: this.battle_room.loading_complete(player); break; case PROTOCOL.MOVING_REQ: { short begin_pos = msg.pop_int16(); short target_pos = msg.pop_int16(); this.battle_room.moving_req(this.player, begin_pos, target_pos); } break; case PROTOCOL.TURN_FINISHED_REQ: this.battle_room.turn_finished(this.player); break; } } | cs |
클라이언트로부터 요청 받아 처리하는 패킷들은 위와 같습니다. 이제 이 패킷들을 하나 하나 처리해보도록 하겠습니다.
게임방 입장 요청
클라이언트가 게임을 실행하고 제일 먼저 하는 일은 게임방 입장 요청입니다. 이 예제는 1:1 실시간으로 진행되는 게임이기 때문에
상대방이 존재해야 게임을 즐길 수 있습니다. 게임방 입장 요청이 오면 CGameServer 클래스의 matching_req 메소드를 호출합니다.
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="user">매칭을 신청한 유저 객체</param> public void matching_req(CGameUser user) { // 대기 리스트에 중복 추가되지 않도록 체크 if (this.matching_waiting_users.Contains(user)) { return; } // 매칭 대기 리스트에 추가. this.matching_waiting_users.Add(user); // 2명이 모이면 매칭 성공. if (this.matching_waiting_users.Count == 2) { // 게임 방 생성. this.room_manager.create_room(this.matching_waiting_users[0], this.matching_waiting_users[1]); // 매칭 대기 리스트 삭제. this.matching_waiting_users.Clear(); } } | cs |
먼저 대기 리스트에 요청한 유저를 추가하고 기다립니다. 추후 또다른 유저에게 입장 요청이 올 경우
대기 리스트에 추가한 뒤 2명이 모이면 게임 방을 생성합니다. 방으로 입장한 유저들은 대기 리스트에서 삭제하고
게임방으로 유저들의 관리를 넘기게 됩니다.
게임 방을 생성하는 CGameRoomManager 클래스의 create_room 메소드로 넘어가보겠습니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | /// <summary> /// 매칭을 요청한 유저들을 넘겨 받아 게임 방을 생성한다. /// </summary> /// <param name="user1"></param> /// <param name="user2"></param> public void create_room(CGameUser user1, CGameUser user2) { // 게임 방을 생성하여 입장 시킴. CGameRoom battleroom = new CGameRoom(); battleroom.enter_gameroom(user1, user2); // 방 리스트에 추가하여 관리한다. this.rooms.Add(battleroom); } | cs |
매칭을 요청한 유저 두 명을 파라미터로 넘겨서 방을 생성합니다. new 연산자를 통해 게임 방 객체를 하나 만들고
enter_gameroom 메소드를 호출하여 유저들을 게임 방으로 입장시킵니다.
하나의 게임 방을 나타내는 CGameRoom 객체는 별도로 풀링처리 하지 않았으므로 그냥 new 연산자로 생성합니다.
만들어진 게임 방 객체는 방 리스트를 관리하는 컨테이너에 보관해놓고 나중에 게임이 종료되었을 때
컨테이너에서 삭제하여 가비지 컬렉터에서 메모리를 회수할 수 있도록 처리해줄 것입니다.
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 | 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); // 로딩 시작 메시지 전송. this.players.ForEach(player => { CPacket msg = CPacket.create((Int16)PROTOCOL.START_LOADING); msg.push(player.player_index); // 본인의 플레이어 인덱스를 알려준다. player.send(msg); }); user1.enter_room(player1, this); user2.enter_room(player2, this); } | cs |
CGameRoom 클래스의 enter_gameroom 메소드의 코드입니다. 게임 방에서는 CGameUser 객체를 사용하지 않고
CPlayer 객체를 사용하므로 new를 통해 플레이어를 생성합니다. CGameUser 객체는 소켓 정보를 포함하고 있는데
게임 방 내에서는 이 정보에 직접적으로 접근할 필요가 없기 때문에 CPlayer라는 객체로 감싸서 사용하는 것입니다.
코딩중 실수를 예방하기 위해서라도 필요하지 않은 정보는 최소한으로 접근하도록 만들 필요가 있습니다.
플레이어들의 초기 상태를 ENTERED_ROOM(방에 입장한 상태)으로 설정해줍니다. 로직을 처리할 때 이 상태에 따라서
해당 요청이 허용 가능한지 여부를 판단하게 됩니다.
다음으로 각각의 플레이어에게 로딩을 시작하라는 START_LOADING 패킷을 전송하고 CGameUser 클래스의
enter_room 메소드를 호출하여 게임 방 입장 처리를 완료합니다.
CGameUser 클래스의 enter_room 메소드에서는 현재 생성된 게임방 객체를 멤버 변수로 보관해 놓은 뒤
유저로부터 게임 로직과 관련된 패킷이 왔을 때 해당 게임 방으로 패킷을 넘겨주는데 사용합니다.
로딩 완료 요청
서버로부터 START_LOADING 패킷을 받은 클라이언트는 각자 로딩을 시작합니다. 디바이스마다 사양이 다르고 네트워크
속도가 모두 다를 수 있기 때문에 로딩을 완료한 클라이언트는 로딩이 끝난 직후 LOADING_COMPLETED 패킷을 전송해야 합니다.
모든 클라이언트로부터 로딩 완료 요청이 들어오면 게임을 시작합니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | /// <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(); } | cs |
클라이언트로부터 LOADING_COMPLETED 패킷을 받으면 CGameRoom 클래스의 loading_complete 메소드를 호출합니다.
먼저 해당 유저의 상태를 로딩 완료 상태로 변경합니다.
그리고 아직 모든 유저가 로딩 완료 상태가 아니라면 return하여 대기 상태로 기다립니다.
잠시 후 다른 유저로부터 LOADING_COMPLETED 패킷을 받으면 해당 유저를 로딩 완료 상태로 변경한 뒤
battle_start 메소드를 호출하여 게임을 시작하도록 합니다.
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 메소드에서는 게임에 관련된 변수들을 초기화하고 플레이어들의 위치를 설정한 뒤
GAME_START 패킷을 클라이언트에게 전달하여 게임을 시작할 수 있도록 해줍니다.
이제 클라이언트로부터 요청이 오기만을 기다리면 됩니다.
이동 요청
게임 서버로부터 GAME_START 패킷을 받은 클라이언트는 그 때부터 게임을 진행할 수 있는 상태가 됩니다.
세균전 게임의 룰에 따라 이동할 세균을 선택합니다. 선택한 이후의 게임 로직은 서버에서 처리되므로
클라이언트에서는 어느 세균을 어느 위치로 이동했다고 알려주면 되겠죠.
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 | void IPeer.process_user_operation(CPacket msg) { PROTOCOL protocol = (PROTOCOL)msg.pop_protocol_id(); Console.WriteLine("protocol id " + protocol); switch (protocol) { case PROTOCOL.ENTER_GAME_ROOM_REQ: Program.game_main.matching_req(this); break; case PROTOCOL.LOADING_COMPLETED: this.battle_room.loading_complete(player); break; case PROTOCOL.MOVING_REQ: { short begin_pos = msg.pop_int16(); short target_pos = msg.pop_int16(); this.battle_room.moving_req(this.player, begin_pos, target_pos); } break; case PROTOCOL.TURN_FINISHED_REQ: this.battle_room.turn_finished(this.player); break; } } | cs |
MOVING_REQ 패킷을 통해 클라이언트로부터 이동 요청이 들어오면 시작 위치와 목적지를 패킷으로부터 뽑아냅니다.
begin_pos와 target_pos 변수에 각각 어디에서 어디로 이동 하겠다는 클라이언트의 요청 값이 들어오게 됩니다.
이 값을 파라미터로 하여 CGameRoom 클래스의 moving_req 메소드를 호출해줍니다.
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, short begin_pos, short target_pos) { // sender 차례인지 체크 if (this.current_turn_player != sender.player_index) { // 현재 턴이 아닌 플레이어가 보낸 요청이라면 무시한다. // 이런 비정상적인 상황에서는 화면이나 파일로 로그를 남겨두는 것이 좋다. return; } // begin_pos에 sender의 캐릭터가 존재하는지 체크 if (this.gameboard[begin_pos] != sender.player_index) { // 시작 위치에 해당 플레이어의 세균이 존재하지 않는다. return; } // 목적지는 EMPTY_SLOT으로 설정된 빈 공간이어야 한다. // 다른 세균이 자리하고 있는 곳으로는 이동할 수 없다. if (this.gameboard[target_pos] != EMPTY_SLOT) { // 목적지에 다른 세균이 존재한다. 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, opponent); // 최종 결과를 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에 이미 다른 세균이 존재 하는지?
- target_pos가 이동 또는 복제 가능한 범위인지?
- 자기 자신의 위치로 이동하려는 것은 아닌지?
위 내용들을 모두 체크하여 하나라도 이상한 경우가 있다면 로직을 처리하지 말고 해당 플레이어의 접속을 끊거나
불이익을 주는 등의 조치를 취해야합니다. 이 예제에서는 조작이 없다고 가정하였기 때문에 리턴 처리만하고 끝냈지만
상용 게임을 개발할 때는 저런 어뷰징 상황에서의 뒷처리까지 깔끔하게 구현해놓아야 합니다.
모든 검사가 정상이라면 해당 위치로 이동 또는 복제한 뒤 상대방 세균을 감염시키는 루틴을 수행합니다.
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 |
infect 메소드에서는 공격자와 방어자 플레이어를 입력 받아 상대방 세균을 감염시키는 일을 수행합니다.
게임의 룰에 따라 공격자의 기준 위치로부터 한칸 이내에 있는 방어자의 세균들이 모두 감염 대상입니다.
해당 위치의 세균을 제거하는 remove_virus와 새로운 세균을 생성하는 put_virus 메소드를 통해
세균의 감염을 처리합니다.
감염 처리가 완료되었으면 클라이언트에게 PLAYER_MOVED 패킷을 전달하여 각자의 클라이언트에서
서버와 동일한 로직을 수행하도록 합니다.
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 |
이때 전송하는 내용은 공격자 클라이언트에서 보내온 begin_pos, target_pos를 그대로 돌려 줍니다.
세균의 감염 처리는 이미 서버에서 완료 되었으며 클라이언트에서는 서버와 동일한 룰을 갖고 자체적으로
감염 처리를 진행하도록 할 것이기 때문에 보드판의 내용을 모두 보낼 필요는 없습니다.
물론 코딩하는 사람에 따라서 디자인이 달라질 수 있으니 반드시 이것과 똑같이 작성해야 하는 것은 아닙니다.
턴 종료 요청
PLAYER_MOVED 패킷을 받은 클라이언트는 각자 세균의 감염 처리를 진행한 뒤 서버에게 턴을 종료한다는
패킷을 보내야합니다. 이 패킷을 받은 뒤 서버는 다음 플레이어로 턴을 넘겨 게임을 진행하게 됩니다.
TURN_FINISHED_REQ패킷을 받아 호출하는 CGameRoom 클래스의 turn_finished 메소드입니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | /// <summary> /// 클라이언트에서 턴 연출이 모두 완료 되었을 때 호출된다. /// </summary> /// <param name="sender"></param> public void turn_finished(CPlayer sender) { change_playerstate(sender, PLAYER_STATE.CLIENT_TURN_FINISHED); if (!allplayers_ready(PLAYER_STATE.CLIENT_TURN_FINISHED)) { return; } // 턴을 넘긴다. turn_end(); } | 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 26 | /// <summary> /// 턴을 종료한다. 게임이 끝났는지 확인하는 과정을 수행한다. /// </summary> void turn_end() { // 보드판 상태를 확인하여 게임이 끝났는지 검사한다. if (!CHelper.can_player_more(this.table_board, get_opponent_player(), this.players)) { game_over(); return; } // 아직 게임이 끝나지 않았다면 다음 플레이어로 턴을 넘긴다. if (this.current_turn_player < this.players.Count -1) { ++this.current_turn_player; } else { // 다시 첫번째 플레이어의 턴으로 만들어준다. this.current_turn_player = this.players[0].player_index; } // 턴을 시작한다. start_turn(); } | cs |
턴을 종료하는 turn_end 메소드의 코드입니다. 다른 플레이어로 턴을 넘기기 전에 게임이 종료되었는지를 검사합니다.
보드 판을 모두 채우거나 더이상 진행할 수 없는 상태라면 게임을 종료시키고 결과 처리를 진행해줍니다.
아직 게임이 끝나지 않았다면 다음 플레이어로 턴을 넘겨줍니다.
1 2 3 4 5 6 7 8 9 10 11 12 | /// <summary> /// 턴을 시작하라고 클라이언트들에게 알려준다. /// </summary> void start_turn() { // 턴을 진행할 수 있도록 준비 상태로 만든다. this.players.ForEach(player => change_playerstate(player, PLAYER_STATE.READY_TO_TURN)); CPacket msg = CPacket.create((short)PROTOCOL.START_PLAYER_TURN); msg.push(this.current_turn_player); broadcast(msg); } | cs |
플레이어의 상태를 준비 상태로 변경한 뒤 START_PLAYER_TURN 패킷을 전송하여 또 다시 유저의 이동 요청을 기다립니다.
게임 종료 처리
턴을 넘기기 전 게임 종료 여부를 검사하는 부분이 있었습니다. 게임이 종료되면 턴을 넘기지 않고 점수를 계산한 뒤
유저들에게 게임 결과를 전송해줍니다.
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 | void game_over() { // 우승자 가리기. byte win_player_index = byte.MaxValue; int count_1p = this.players[0].get_virus_count(); int count_2p = this.players[1].get_virus_count(); if (count_1p == count_2p) { // 동점인 경우 win_player_index = byte.MaxValue; } else { if (count_1p > count_2p) { win_player_index = this.players[0].player_index; } else { win_player_index = this.players[1].player_index; } } CPacket msg = CPacket.create((short)PROTOCOL.GAME_OVER); msg.push(win_player_index); msg.push(count_1p); msg.push(count_2p); broadcast(msg); // 방 제거. Program.game_main.room_manager.remove_room(this); } | cs |
게임 결과에 따라서 동점일 경우에는 win_player_index에 byte.MaxValue를 넣어서 동점이라는 표시를 해줍니다.
우승자가 있을 경우에는 win_player_index에 우승자의 인덱스를 넣어주고 유저들에게 GAME_OVER 패킷을 전달합니다.
이 패킷을 받은 유저들은 각자의 화면에 게임 결과 화면을 출력해주고 더이상의 게임 진행이 안되도록 처리합니다.
서버에서는 현재 방을 더이상 유지할 필요가 없으므로 CGameRoomManager 클래스의 remove_room 메소드를 호출하여
게임 방을 서버에서 삭제해줍니다.
'- Programming > - C#' 카테고리의 다른 글
★ 16. C# - List 사용 예제 (2) | 2017.03.22 |
---|---|
★ 15. c# ref와 out의 차이 (0) | 2017.02.16 |
★ 13. c# 네트워크 개발 p12 (0) | 2017.02.16 |
★ 12. c# 네트워크 개발 p11 (0) | 2017.02.16 |
★ 11. c# 네트워크 개발 p10 (0) | 2017.02.15 |