네트워크 수업을 듣다가 과제로 나온 서버 - 클라이언트 방식의 간단한 게임을 만들어보자
과제의 요구사항을 먼저 살펴보자
클라이언트 요구사항
3. 클라이언트는 서버로부터 주기적으로 게임 상태 업데이트 메시지를 수신한다. 해당 메시지에는 내 캐릭터를 포함한 연결된 모든 사용자들의 캐릭터 상태 정보가 담겨있으며, 메시지를 수신할 때마다 해당 정보들을 화면에 출력한다
서버 요구사항
사용자 캐릭터의 상태 정보는 아래 4가지 필드로 구성된다.
캐릭터 이름(영문 10자 이내)
캐릭터 위치 좌표(x, y)
캐릭터 체력(초기 값 500으로 고정)
캐릭터 공격력(초기 값 10으로 고정)
애플리케이션 계층 프로토콜 개발 요구사항
헤더 정보
Message type + Data length + Data
메시지 타입은 게임에서 사용하는 다양한 메시지 종류를 나타낸다. 주요 메시지 유형은 다음과 같다.
0x00: 연결 요청 – Data 필드에 내 캐릭터의 이름이 저장되어 있음을 나타냄
0x01: 위치 업데이트 – Data 필드에 내 캐릭터의 현재 좌표가 저장되어 있음을 나타냄
0x02: 행동 명령 – Data 필드에 내 캐릭터의 공격 명령이 저장되어 있음을 나타냄
0x03: 상태 업데이트 – Data 필드에 모든 플레이어들의 상태 정보가 저장되어 있음을 나타냄
통신은 소캣을 사용할 것이다.
일단 설계를 해보자면
서버에서는 각 캐릭터 정보를 동기화 하기 위하여 캐릭터를 구조체로 선언해주면 될 것 같다. 물론 클래스로 선언해도 되겠지만 생성자가 굳이 필요하진 않기때문에 구조체를 사용해주자. 그리고 각 플레이어의 핑을 수집한 다음 가장 긴 핑으로
정보 동기화를 실행해주자.
클라이언트에서는 쓰레드를 활용하여 서버에서 보내주는 메세지를 처리해주고 입력도 받을 수 있도록 했다.
서버에서도 쓰레드를 활용하여 여러 클라이언트의 메세지를 동시에 처리할 수 있도록 했다.
Server.cpp
#include <iostream>
#include <string>
#include <map>
#include <vector>
#include <thread> // 스레드 사용
#include <chrono> // 핑 계산을 위한 타이머
#include <winsock2.h>
#include <ws2tcpip.h>
#include <windows.h>
// 캐릭터 구조체 정의
struct Character {
std::string name;
int x = 0, y = 0;
int health = 500;
int attackPower = 10;
};
// 메시지 타입 정의
enum MessageType {
CONNECTION_REQUEST = 0x00,
POSITION_UPDATE = 0x01,
ACTION_COMMAND = 0x02,
STATUS_UPDATE = 0x03,
PING_REQUEST = 0x04,
PING_RESPONSE = 0x05
};
// 메시지 구조체 정의
struct Message {
MessageType type;
uint32_t data_length;
char data[256];
};
// sockaddr_in 구조체를 비교하기 위한 함수 (IP와 포트 번호를 함께 비교)
bool compareSockaddr(const sockaddr_in &a, const sockaddr_in &b) {
return a.sin_addr.s_addr == b.sin_addr.s_addr && a.sin_port == b.sin_port;
}
// 서버 클래스 정의
class GameServer {
public:
std::map<int, Character> players;
std::vector<sockaddr_in> client_addresses;
std::map<int, int> client_ping_times; // 클라이언트 별 핑 값을 저장
int client_counter = 0;
// 클라이언트 ID 할당
int getClientID(sockaddr_in client_addr) {
for (size_t i = 0; i < client_addresses.size(); ++i) {
if (compareSockaddr(client_addresses[i], client_addr)) {
return i;
}
}
client_addresses.push_back(client_addr);
return client_counter++;
}
// 핑 요청 및 응답 처리
void handlePing(SOCKET sockfd, sockaddr_in client_addr, int addr_len, int clientID) {
Message pingRequest;
pingRequest.type = PING_REQUEST;
pingRequest.data_length = 0;
// PING_REQUEST 전송
sendto(sockfd, (char*)&pingRequest, sizeof(pingRequest), 0, (struct sockaddr*)&client_addr, addr_len);
// PING 응답 처리
Message pingResponse;
auto start = std::chrono::high_resolution_clock::now();
recvfrom(sockfd, (char*)&pingResponse, sizeof(pingResponse), 0, (struct sockaddr*)&client_addr, &addr_len);
if (pingResponse.type == PING_RESPONSE) {
auto end = std::chrono::high_resolution_clock::now();
int pingTime = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
client_ping_times[clientID] = pingTime;
std::cout << "Ping time for client " << clientID << ": " << pingTime << " ms" << std::endl;
}
}
// 메시지 처리 함수 (스레드 내에서 실행)
void handleClient(SOCKET sockfd, sockaddr_in client_addr, Message message, int addr_len) {
int clientID = getClientID(client_addr);
processMessage(clientID, message);
// 핑 요청 및 처리
handlePing(sockfd, client_addr, addr_len, clientID);
// 가장 큰 핑을 기준으로 상태 브로드캐스트
int max_ping = getMaxPingTime();
Sleep(max_ping); // 가장 높은 핑만큼 대기
broadcastState(sockfd, addr_len);
}
// 메시지 처리
void processMessage(int clientID, Message message) {
switch (message.type) {
case CONNECTION_REQUEST:
players[clientID].name = std::string(message.data);
std::cout << "Player connected: " << players[clientID].name << std::endl;
break;
case POSITION_UPDATE:
int x, y;
sscanf(message.data, "%d %d", &x, &y);
players[clientID].x = x;
players[clientID].y = y;
std::cout << "Player " << clientID << " moved to (" << x << ", " << y << ")\n";
break;
case ACTION_COMMAND:
int targetID;
sscanf(message.data, "%d", &targetID);
if (players.find(targetID) != players.end()) {
players[targetID].health -= players[clientID].attackPower;
std::cout << "Player " << clientID << " attacked Player " << targetID << std::endl;
}
break;
default:
break;
}
}
// 모든 클라이언트에 상태 전송
void broadcastState(SOCKET sockfd, int addr_len) {
Message message;
message.type = STATUS_UPDATE;
std::string status;
for (const auto &player : players) {
status += player.second.name + ": (" + std::to_string(player.second.x) + ", " +
std::to_string(player.second.y) + ") HP: " + std::to_string(player.second.health) + "\n";
}
strncpy(message.data, status.c_str(), sizeof(message.data) - 1);
message.data[sizeof(message.data) - 1] = '\0';
// 각 클라이언트에 상태 정보 전송
for (const auto &client_addr : client_addresses) {
sendto(sockfd, (char*)&message, sizeof(message), 0, (struct sockaddr *)&client_addr, addr_len);
}
}
// 가장 높은 핑 값을 반환
int getMaxPingTime() {
int max_ping = 0;
for (const auto &entry : client_ping_times) {
if (entry.second > max_ping) {
max_ping = entry.second;
}
}
return max_ping;
}
};
// 서버 메인 코드
int main() {
WSADATA wsaData;
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) {
std::cerr << "WSAStartup failed" << std::endl;
return 1;
}
SOCKET sockfd;
sockaddr_in server_addr, client_addr;
int addr_len = sizeof(client_addr);
// 소켓 생성
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd == INVALID_SOCKET) {
std::cerr << "Socket creation failed: " << WSAGetLastError() << std::endl;
WSACleanup();
return 1;
}
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(8080);
// 바인딩
if (bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == SOCKET_ERROR) {
std::cerr << "Bind failed: " << WSAGetLastError() << std::endl;
closesocket(sockfd);
WSACleanup();
return 1;
}
GameServer gameServer;
// 메인 루프
while (true) {
Message message;
recvfrom(sockfd, (char*)&message, sizeof(message), 0, (struct sockaddr *)&client_addr, &addr_len);
// 각 클라이언트 메시지를 별도의 스레드에서 처리
std::thread clientThread(&GameServer::handleClient, &gameServer, sockfd, client_addr, message, addr_len);
clientThread.detach(); // 스레드가 종료되면 자동으로 자원 반환
}
closesocket(sockfd);
WSACleanup();
return 0;
}
Client.cpp
#include <iostream>
#include <string>
#include <thread> // 비동기 처리를 위한 스레드 사용
#include <winsock2.h>
#include <ws2tcpip.h>
#include <windows.h> // Windows API 타이머 및 Sleep 함수 사용
// 메시지 타입 정의
enum MessageType {
CONNECTION_REQUEST = 0x00,
POSITION_UPDATE = 0x01,
ACTION_COMMAND = 0x02,
STATUS_UPDATE = 0x03
};
// 메시지 구조체 정의
struct Message {
MessageType type;
uint32_t data_length;
char data[256];
};
// 클라이언트 함수
void sendMessage(SOCKET sockfd, sockaddr_in server_addr, Message message) {
int result = sendto(sockfd, (char*)&message, sizeof(message), 0, (struct sockaddr*)&server_addr, sizeof(server_addr));
if (result == SOCKET_ERROR) {
std::cerr << "Error sending message: " << WSAGetLastError() << std::endl;
}
}
// 서버에서 메시지 수신 (비동기)
void receiveMessage(SOCKET sockfd) {
Message message;
sockaddr_in server_addr;
int addr_len = sizeof(server_addr);
while (true) {
int result = recvfrom(sockfd, (char*)&message, sizeof(message), 0, (struct sockaddr*)&server_addr, &addr_len);
if (result == SOCKET_ERROR) {
std::cerr << "Error receiving message: " << WSAGetLastError() << std::endl;
} else {
if (message.type == STATUS_UPDATE) {
std::cout << "\nGame State:\n" << message.data << std::endl;
}
}
}
}
int main() {
WSADATA wsaData;
int result = WSAStartup(MAKEWORD(2, 2), &wsaData); // Winsock 초기화
if (result != 0) {
std::cerr << "WSAStartup failed: " << result << std::endl;
return 1;
}
SOCKET sockfd;
sockaddr_in server_addr;
// 소켓 생성
sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd == INVALID_SOCKET) {
std::cerr << "Socket creation failed: " << WSAGetLastError() << std::endl;
WSACleanup();
return 1;
}
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
server_addr.sin_port = htons(8080);
// 캐릭터 이름 설정 및 서버 연결 요청
std::string playerName;
std::cout << "Enter your character name: ";
std::cin >> playerName;
Message connectMessage;
connectMessage.type = CONNECTION_REQUEST;
// strncpy로 버퍼에 복사 및 널 문자 처리
strncpy(connectMessage.data, playerName.c_str(), sizeof(connectMessage.data) - 1);
connectMessage.data[sizeof(connectMessage.data) - 1] = '\0'; // 널 문자 추가
connectMessage.data_length = playerName.size();
sendMessage(sockfd, server_addr, connectMessage);
// 비동기적으로 서버에서 메시지를 수신하는 스레드 시작
std::thread receiveThread(receiveMessage, sockfd);
receiveThread.detach(); // 비동기 수신
// 메인 루프: 사용자 입력 처리
while (true) {
std::cout << "1. Move\n2. Attack\nChoose an action: ";
int choice;
std::cin >> choice;
if (choice == 1) {
// 위치 업데이트
int x, y;
std::cout << "Enter new coordinates (x y): ";
std::cin >> x >> y;
Message moveMessage;
moveMessage.type = POSITION_UPDATE;
snprintf(moveMessage.data, sizeof(moveMessage.data), "%d %d", x, y);
moveMessage.data_length = strlen(moveMessage.data);
sendMessage(sockfd, server_addr, moveMessage);
} else if (choice == 2) {
// 공격 명령
int targetID;
std::cout << "Enter target player ID: ";
std::cin >> targetID;
Message attackMessage;
attackMessage.type = ACTION_COMMAND;
snprintf(attackMessage.data, sizeof(attackMessage.data), "%d", targetID);
attackMessage.data_length = strlen(attackMessage.data);
sendMessage(sockfd, server_addr, attackMessage);
}
}
closesocket(sockfd); // 소켓 닫기
WSACleanup(); // Winsock 종료
return 0;
}
실행은 VS code의 Terminal을 통해 해주자 일단 컴파일해주기 위하여 MinGW를 설치해주자 다운로드는 밑의 페이지에서 제일 최선버전 zip을 받아준다음 원하는 위치에 압축해제해주고 환경변수 설정만 해주면된다.
https://winlibs.com/#download-release
WinLibs - GCC+MinGW-w64 compiler for Windows
WinLibs standalone build of GCC and MinGW-w64 for Windows Jump to: Download | How to use from Windows Command Prompt | How to use from Code::Blocks | Philosophy | Donate What is it? In short: it's a free C and C++ compiler for Microsoft Windows. GCC (GN
winlibs.com
환경 변수는 Path에서 밑에 그림같이 추가해주면 된다.
cmd창에서 ggc --version했을때 다음과 같은 화면이 나오면 올바르게 설치된 것이다.
이렇게 해준다음 컴파일 해주고 exe파일이 문제없이 생긴다면
g++ server.cpp -o server.exe -lws2_32 -Wall
g++ client.cpp -o client.exe -lws2_32 -Wall
다음과 같이 실행해주고 테스트해주면 된다.
./server.exe
./client.exe
실행화면
이렇게 해주면 간단한 온라인게임이 완성된다.