25.10.15 개발일지 / C# 명코파크 (3일차)

2025. 11. 13. 17:12·LMS 7/개발일지

로그인(닉네임 입력)

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
'LMS 7/개발일지' 카테고리의 다른 글
  • 25.10.17 개발일지 / C# 명코파크 (5일차)
  • 25.10.16 개발일지 / C# 명코파크 (4일차)
  • 25.10.14 개발일지 / C# 명코파크 (2일차)
  • 25.10.13 개발일지 / C# 명코파크 (1일차)
m_Dev
m_Dev
  • m_Dev
    m_Dev
    m_Dev
  • 전체
    오늘
    어제
    • 분류 전체보기
      • MAIN STUDY
        • 정보보안
        • 빅데이터
        • 정보처리
        • 컴퓨터 구조
        • 기타
      • JOB
        • Study
        • Project
      • LMS 7
        • 개발일지
      • FRAMEWORK
        • Qt
        • MFC
        • Winform
        • WPF
        • MAUI
      • NETWORK
        • Study
        • Assignment
      • PYTHON
        • Set
        • Study
        • Assignment
        • Project
      • C
        • Set
        • Study
        • Assignment
        • Project
      • C++
        • Set
        • Study
        • Assignment
        • Project
      • C#
        • Set
        • Study
        • Assignment
        • Project
      • DATABASE
        • MySQL
        • SQLite
      • IDE
        • VisualStudioCode
        • VisualStudio
        • Pycharm
        • Colab
      • 기타
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
m_Dev
25.10.15 개발일지 / C# 명코파크 (3일차)
상단으로

티스토리툴바