로그인(닉네임 입력)
1. SERVER
<GameServer.cs>
// 리스너, 클라이언트 List 준비
private readonly TcpListener _listener;
private readonly List<TcpClient> _clients = new List<TcpClient>();
// 서버의 모든 플레이어 관리용 딕셔너리
private readonly Dictionary<TcpClient, PlayerState> _players = new Dictionary
// 로그인시 초기화 될 플레이어 위치
private readonly PointF[] spawnPositions = new PointF[]
{
new PointF(0, 0),
new PointF(100, 0),
new PointF(200, 0),
new PointF(300, 0)
};
public async Task StartAsync()
{
// 리스너를 통해 시작
_listener.Start();
Console.WriteLine("Server Start... Port is 5000");
_ = PhysicsLoop();
while (true)
{
// 클라이언트를 클라이언트 List인 _clients에 추가
var client = await _listener.AcceptTcpClientAsync();
_clients.Add(client);
Console.WriteLine($"클라이언트 연결됨({client.Client.RemoteEndPoint})");
_ = HandleClientAsync(client); // 비동기 수신(await과 다르게 기다리지 않고 무시)
}
}
private async Task HandleClientAsync(TcpClient client)
{
var stream = client.GetStream();
try
{
while (true)
{
// Packet의 ReceiveAsync 메서드는 stream 객체로 header와 json을 반환함
var (header, json) = await Packet.ReceiveAsync(stream);
Console.WriteLine($"[수신] {header.MsgType} : {json}");
switch (header.MsgType)
{
// 로그인
case MessageType.LOGIN_REQ:
await HandleLoginAsync(client, json);
break;
// 이동 및 점프
case MessageType.INPUT:
var input = JsonSerializer.Deserialize<KeyState>(json)!;
var player = _players[client];
const float speed = 5f;
// 좌우
if (input.Left) player.x -= speed;
if(input.Right) player.x += speed;
// 점프
if(input.Jump && !player.IsJumping)
{
player.vy = JumpPower;
player.IsJumping = true;
}
_players[client] = player;
await BroadcastAsync(MessageType.STATE_SHORT, _players.Values);
break;
}
}
}
catch(Exception ex)
{
Console.WriteLine($"클라이언트 종료 : {ex.Message}");
_clients.Remove(client);
client.Close();
}
}
private async Task HandleLoginAsync(TcpClient client, string json)
{
var player = JsonSerializer.Deserialize<PlayerState>(json)!;
Console.WriteLine($"[LOGIN] {player.Nickname} 접속");
// PlayerState를 가진 player의 정보를 차례대로 입력
player.Id = _players.Count + 1;
// 고정 스폰
if(player.Id - 1 < spawnPositions.Length)
{
player.x = spawnPositions[player.Id - 1].X;
player.y = spawnPositions[player.Id - 1].Y;
}
else // 4명 초과 시 랜덤 스폰
{
player.x = new Random().Next(0, 400);
player.y = 0;
}
player.HasStar = false;
player.IsCleared = false;
// 플레이어 딕셔너리에 client를 키로 player를 값으로 저장
// 이제 client 별로 player 정보를 사용 가능
_players[client] = player;
Console.WriteLine($"[INIT] {player.Nickname}: ({player.x},{player.y})");
// 로그인 성공 메시지
var ack = new { message = $"환영합니다, {player.Nickname}!" };
await Packet.SendAsync(client.GetStream(), MessageType.LOGIN_ACK, ack);
// 새로 들어온 플레이어 정보 브로드 캐스트
await BroadcastAsync(MessageType.STATE_LONG, player);
Console.WriteLine($"[STATE_LONG] {player.Nickname} ({player.x}, {player.y}) 전송 완료");
}
private async Task BroadcastAsync(MessageType type, object bodyObj)
{
foreach (var c in _clients)
{
try
{
await Packet.SendAsync(c.GetStream(), type, bodyObj);
}
catch { }
}
}
2. CLIENT
<GameClient.cs>
// 수신 이벤트
public event Action<MessageType, string>? OnPacketReceived;
// 연결 및 수신
public async Task ConnectAsync(string host, int port)
{
// 서버 연결
_client = new TcpClient();
await _client.ConnectAsync(host, port);
_stream = _client.GetStream();
// 수신 루프
_ = Task.Run(async () =>
{
while (true)
{
var (header, json) = await Packet.ReceiveAsync(_stream);
// 이벤트는 여기서 지속적으로 호출되어 구독된 메서드를 실행함
OnPacketReceived?.Invoke(header.MsgType, json);
}
});
}
// 로그인 요청 전송
public async Task SendLoginAsync(string nickname)
{
var player = new PlayerState
{
Nickname = nickname,
x = 0,
y = 0
};
await Packet.SendAsync(_stream, MessageType.LOGIN_REQ, player);
}
<LoginForm.cs>
private async void LoginForm_Load(object sender, EventArgs e)
{
await _client.ConnectAsync("127.0.0.1", 5000);
listBox1.Items.Add("서버와 연결");
listBox1.Items.Add("하단에 닉네임을 입력하고 입장하세요.");
// 이벤트에 핸들러 메서드 구독
_client.OnPacketReceived += HandleServerMessage;
}
// SendLoginAsync로 로그인 요청(LOGIN_REQ)을 서버에 전송
private async void btnLogin_Click(object sender, EventArgs e)
{
if (string.IsNullOrWhiteSpace(txtNickname.Text))
{
MessageBox.Show("닉네임을 입력하세요 !");
return;
}
await _client.SendLoginAsync(txtNickname.Text);
listBox1.Items.Add($"로그인 요청 : {txtNickname.Text}");
txtNickname.Enabled = false;
btnLogin.Enabled = false;
}
// 로그인 요청(LOGIN_REQ)에 대한 서버의 로그인 응답(LOGIN_ACK)을 처리
// 먼저 서버의 수신으로 인해 구독한 이벤트가 호출됨
// 이후 이벤트에 구독된 아래의 메서드가 실행됨
private void HandleServerMessage(MessageType type, string json)
{
switch (type)
{
case MessageType.LOGIN_ACK:
var ack = JsonSerializer.Deserialize<Dictionary<string, string>>(json);
// ack가 null이 아니라면 message를 꺼내고, null이라면 null로 처리
// ?? 왼쪽 값이 null이라면 오른쪽 값을, null 이라면 null을 사용
string message = ack?["message"] ?? "로그인 성공";
// 폼(UI) 스레드에서 블록을 실행
Invoke(() =>
{
listBox1.Items.Add(message);
MessageBox.Show(message);
string nickname = txtNickname.Text.Trim();
GameStart(nickname, _client);
});
break;
}
}
키 입력 및 이동, 점프
0. CLIENT : LoginForm -> ClientForm
<LoginForm.cs>
private void GameStart(string nickname, GameClient client)
{
// 로그인 폼이 가지고 있던 닉네임, 클라이언트를 게임 폼에 넘김
GameForm gameForm = new GameForm(nickname, client);
// 게임 폼의 핸들러 메서드도 이 때 구독
client.OnPacketReceived += gameForm.HandleServerMessage;
// 게임 폼을 켜고, 로그인 폼은 숨김
gameForm.Show();
this.Hide();
}
1. SERVER
<GameServer.cs>
// 점프를 위한 물리요소
private const float Gravity = 0.6f;
private const float JumpPower = 10f;
private const float GroundY = 0f;
private const int TickRate = 30; // 30ms 마다
private async Task PhysicsLoop()
{
while (true)
{
foreach (var kv in _players.ToList())
{
// kv.Value는 PlayerState임
var player = kv.Value;
// 점프 중이면 속도만큼 증가
player.y += player.vy;
// 속도는 중력만큼 감소
player.vy -= Gravity;
// 플레이어의 y좌표가 바닥(GroundY)보다 내려가면
// 강제로 바닥에 붙이고, 점프를 종료
if(player.y < GroundY)
{
player.y = GroundY;
player.vy = 0;
player.IsJumping = false;
}
// kv.Key는 TcpClient임
// 수정된 player 객체를 다시 딕셔너리에 저장
_players[kv.Key] = player;
}
await BroadcastAsync(MessageType.STATE_SHORT, _players.Values);
// 루프가 빨리 돌지 않도록 지연시킴
await Task.Delay(TickRate);
}
}
public async Task StartAsync()
{
_listener.Start();
Console.WriteLine("Server Start... Port is 5000");
_ = PhysicsLoop(); // 시작과 동시에 비동기로 계속 메서드 실행됨
while (true)
{
var client = await _listener.AcceptTcpClientAsync();
_clients.Add(client);
Console.WriteLine($"클라이언트 연결됨({client.Client.RemoteEndPoint})");
_ = HandleClientAsync(client); // 비동기 수신(await과 다르게 기다리지 않고 무시)
}
}
private async Task HandleClientAsync(TcpClient client)
{
var stream = client.GetStream();
try
{
while (true)
{
// Packet의 ReceiveAsync 메서드는 stream 객체로 header와 json을 반환함
var (header, json) = await Packet.ReceiveAsync(stream);
Console.WriteLine($"[수신] {header.MsgType} : {json}");
switch (header.MsgType)
{
// 로그인
case MessageType.LOGIN_REQ:
await HandleLoginAsync(client, json);
break;
// 이동 및 점프
case MessageType.INPUT:
// json 객체를 KeyState 형 객체로 바꿔 저장
var input = JsonSerializer.Deserialize<KeyState>(json)!;
// player 객체에 딕셔너리 값을 복사
var player = _players[client];
// 플레이어의 이동속도
const float speed = 5f;
// 좌우
if (input.Left) player.x -= speed;
if(input.Right) player.x += speed;
// 점프
if(input.Jump && !player.IsJumping)
{
// 점프력만큼 수직 이동속도를 설정
player.vy = JumpPower;
// 점핑 상태 True 변경 -> PhysicsLoop() 에서 수직 좌표 변경 후 다시 False로 변경
player.IsJumping = true;
}
// 다시 딕셔너리 값에 수정된 player를 복사
_players[client] = player;
await BroadcastAsync(MessageType.STATE_SHORT, _players.Values);
break;
}
}
}
catch(Exception ex)
{
Console.WriteLine($"클라이언트 종료 : {ex.Message}");
_clients.Remove(client);
client.Close();
}
}
2. CLIENT
<GameForm.cs>
// 움직임 키
bool leftPressed = false;
bool rightPressed = false;
bool jumpPressed = false;
// Id와 PictureBox 딕셔너리를 통해 본인 포함 플레이어 관리
Dictionary<int, PictureBox> players = new Dictionary<int, PictureBox>();
// Key가 눌렸을 때
private async void KeyIsDown(object sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.Left) leftPressed = true;
if (e.KeyCode == Keys.Right) rightPressed = true;
if (e.KeyCode == Keys.Space) jumpPressed = true;
e.SuppressKeyPress = true; // 키 입력이 다른 컨트롤로 이동하지 않도록 추가
}
// Key가 떼어졌을 때
private async void KeyIsUp(object sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.Left) leftPressed = false;
if (e.KeyCode == Keys.Right) rightPressed = false;
if (e.KeyCode == Keys.Space) jumpPressed = false;
e.SuppressKeyPress = true; // 키 입력이 다른 컨트롤로 이동하지 않도록 추가
}
private async void MoveTimer_tick(object sender, EventArgs e)
{
await _client.SendInputAsync(new CommonProtocol.KeyState
{
Left = leftPressed,
Right = rightPressed,
Jump = jumpPressed
});
}
▲ 여기서 지속적으로 변경된 Left, Right, Jump를 전송함
▲ MoveTimer_tick은 Timer 이벤트(Tick)에 구독되어 있는 메서드임
internal void HandleServerMessage(MessageType type, string json)
{
switch (type)
{
case MessageType.STATE_LONG:
// 로그인한 클라이언트 하나만을 json으로 기대함
var player = JsonSerializer.Deserialize<PlayerState>(json);
if (player != null)
Invoke(() => UpdateOrCreateCharacter(player));
break;
case MessageType.STATE_SHORT:
// 여러 클라이언트의 이동을 가진 json을 List 형식으로 기대함
var list = JsonSerializer.Deserialize<List<PlayerState>>(json);
if (list != null)
{
// 디버그용
listBox2.Items.Add($"[STATE_SHORT] 수신됨 ({list.Count}명)");
foreach (var p in list)
listBox2.Items.Add($"-> {p.Nickname} ({p.x}, {p.y})");
// 실제 분기
Invoke(() =>
{
foreach (var p in list)
UpdateOrCreateCharacter(p);
});
}
break;
}
}
▲ 0. 에서 이벤트에 구독된 메서드임
▲ 현재 로그인시 STATE_LONG으로 한번, 이동 및 점프에 대한 상태변경시 STATE_SHORT로 지속적으로 메시지 타입을 받아 다음 메서드를 호출함
private void UpdateOrCreateCharacter(PlayerState player)
{
listBox2.Items.Add($"[UPDATE] {player.Nickname} -> ({player.x}, {player.y})");
// ground는 바닥을 의미
var ground = floors.FirstOrDefault();
int groundY = ground?.Top ?? this.ClientSize.Height - 40; // 바닥 두께 빼기
int chaHeight = 50; // 캐릭터 높이
// render 좌표를 다시 구하는 이유
// 서버에서는 y가 0부터 커지기 시작하는데(player.y)
// 윈폼에서는 y가 커질수록 아래로 가기 때문에 좌표가 다름
// 따라서 groundY(바닥)에서 player.y를 빼서 뒤집어 계산함
// x는 같다
int renderY = groundY - (int)player.y - chaHeight;
int renderX = (int)player.x;
// players는 int와 PictureBox를 반환하는 딕셔너리
// TryGetValue 메서드를 통해 키(Id)가 존재하면 값(pb)을 반환받음
// 만약 있다면(if) 위치(Location)만 반영
if(players.TryGetValue(player.Id, out var pb))
{
pb.Location = new Point(renderX, renderY);
}
// 없다면 새롭게 pb(PictureBox) 생성
else
{
pb = new PictureBox
{
Name = player.Nickname,
Size = new Size(chaHeight, chaHeight),
SizeMode = PictureBoxSizeMode.StretchImage,
Image = player.Nickname == _nickname
? Properties.Resources.RED
: Properties.Resources.BLUE,
Location = new Point(renderX, renderY)
};
listBox2.Items.Add($"STATE_LONG: {player.Nickname} ({player.x}, {player.y})");
Controls.Add(pb); // 폼에 실제 추가
pb.BringToFront(); // 다른 컨트롤 위에 올림
players[player.Id] = pb; // 딕셔너리에도 저장
}
}
실행영상
동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.
> 시작화면은 나중에 꾸며야겠음
'LMS 7 > 개발일지' 카테고리의 다른 글
| 25.10.17 개발일지 / C# 명코파크 (5일차) (0) | 2025.11.13 |
|---|---|
| 25.10.16 개발일지 / C# 명코파크 (4일차) (0) | 2025.11.13 |
| 25.10.14 개발일지 / C# 명코파크 (2일차) (0) | 2025.11.13 |
| 25.10.13 개발일지 / C# 명코파크 (1일차) (0) | 2025.11.13 |
| 25.10.09 개발일지 / C# TRex - TCP IP 네트워크 구상 (0) | 2025.11.13 |