이동 및 점프 구현
전에 이동과 점프에 대한 입력은 INPUT으로 모두 받을 수 있었다.
이제 이걸 어떻게 처리를 할지가 문제임
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 마다
// 점프를 위한 루프
// StartAsync() 에서 비동기적으로 계속 실행됨
private async Task PhysicsLoop()
{
while (true)
{
List<KeyValuePair<TcpClient, PlayerState>> snapshot;
lock (_lockPlayers)
snapshot = _players.ToList();
foreach (var kv in snapshot)
{
var player = kv.Value;
// 점프 중이면 Y 속도 적용
player.y += player.vy;
// 중력 적용
player.vy -= Gravity;
// 바닥 충돌의 경우
if (player.y < GroundY)
{
player.y = GroundY;
player.vy = 0;
player.IsJumping = false;
}
lock (_lockPlayers)
_players[kv.Key] = player;
}
List<PlayerState> states;
lock (_lockPlayers)
states = _players.Values.ToList();
await BroadcastAsync(MessageType.STATE_SHORT, states);
await Task.Delay(TickRate);
}
}
public async Task StartAsync()
{
_listener.Start();
Console.WriteLine("Server Start... Port is 5000");
_ = PhysicsLoop(); // 점프 관련 루프
_ = TimeoutLoop(); // 접속해제 관련 루프
while (true)
{
var client = await _listener.AcceptTcpClientAsync();
_lastSeen[client] = DateTime.UtcNow; // 접속 시간 기록
lock (_lockClients) _clients.Add(client);
Console.WriteLine($"클라이언트 연결됨({client.Client.RemoteEndPoint})");
_ = HandleClientAsync(client); // 비동기 수신(await과 다르게 기다리지 않고 무시)
}
}
private async Task HandleClientAsync(TcpClient client)
{
var stream = client.GetStream();
try
{
while (true)
{
var (header, json) = await Packet.ReceiveAsync(stream);
_lastSeen[client] = DateTime.UtcNow;
//Console.WriteLine($"[수신] {header.MsgType} : {json}");
switch (header.MsgType)
{
case MessageType.LOGIN_REQ:
await HandleLoginAsync(client, json);
break;
case MessageType.INPUT: // 유저의 키 입력은 INPUT으로 들어오고, 이 case로 처리됨
var input = JsonSerializer.Deserialize<KeyState>(json)!;
PlayerState player;
lock (_lockPlayers) 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;
}
lock (_lockPlayers) _players[client] = player;
List<PlayerState> states;
lock (_lockPlayers) states = _players.Values.ToList();
// 이동 및 점프에 관한 정보는 STATE_SHORT로 브로드캐스트함.
await BroadcastAsync(MessageType.STATE_SHORT, states);
break;
}
}
}
catch (Exception ex)
{
Console.WriteLine($"클라이언트 종료 : {ex.Message}");
await DisconnectAsync(client, "exception");
}
}
2. CLIENT
<GameForm.cs>
internal void HandleServerMessage(MessageType type, string json)
{
switch (type)
{
case MessageType.STATE_LONG:
var player = JsonSerializer.Deserialize<PlayerState>(json);
if (player != null)
Invoke(() =>
{
if (player.IsLeaving)
{
listBox2.Items.Add($"[REMOVE] {player.Nickname} (ID:{player.Id})");
RemoveCharacter(player.Id);
}
else
{
UpdateOrCreateCharacter(player);
}
});
break;
case MessageType.STATE_SHORT: // 이동 및 점프 처리부
// 모든 클라이언트들의 정보를 업데이트 해야하므로 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;
}
}
private void UpdateOrCreateCharacter(PlayerState player)
{
listBox2.Items.Add($"[UPDATE] {player.Nickname} -> ({player.x}, {player.y})");
var ground = floors.FirstOrDefault();
int groundY = ground?.Top ?? this.ClientSize.Height - 40; // 바닥 두께 빼기
int chaHeight = 50; // 캐릭터 높이
int renderY = groundY - (int)player.y - chaHeight;
int renderX = (int)player.x;
int direction = 0; // 0: 정지, -1: 왼쪽, +1: 오른쪽
if(prevX.TryGetValue(player.Id, out var prev))
{
if (player.x < prev) direction = -1;
else if(player.x > prev) direction = 1;
}
// 현재 좌표 저장
prevX[player.Id] = player.x;
// 이미 Id에 맞는 PictureBox가 생성되어 있다면 위치만 변경(이동 및 점프 시)
if (players.TryGetValue(player.Id, out var pb))
{
pb.Location = new Point(renderX, renderY);
// 방향에 따라 이미지 변경
if(direction != 0)
{
pb.Image = (direction == -1)
? PlayerColorsLeft[(player.Id - 1) % PlayerColorsLeft.Length]
: PlayerColorsRight[(player.Id - 1) % PlayerColorsRight.Length];
}
}
// 아닌 경우에는 PictureBox를 새로 생성한다(LOGIN 시)
else
{
pb = new PictureBox
{
Name = player.Nickname,
Size = new Size(chaHeight, chaHeight),
SizeMode = PictureBoxSizeMode.StretchImage,
Image = PlayerColorsRight[(player.Id - 1) % PlayerColorsRight.Length],
Location = new Point(renderX, renderY),
BackColor = Color.Transparent
};
listBox2.Items.Add($"STATE_LONG: {player.Nickname} ({player.x}, {player.y})");
Controls.Add(pb);
pb.BringToFront();
players[player.Id] = pb;
prevX[player.Id] = player.x;
}
}
이미지 추가 및 방향키에 따른 이미지 변경
1. CLIENT

> 먼저 resources(리소스)에 모두 추가함
<GameForm.cs>
// Id와 PictureBox 딕셔너리를 통해 본인을 포함한 모든 플레이어 관리
Dictionary<int, PictureBox> players = new Dictionary<int, PictureBox>();
// 이전 프레임 좌표 기억
// 이것과 현재를 비교해 왼쪽, 오른쪽을 구분함
private Dictionary<int, float> prevX = new Dictionary<int, float>();
// 좌우 이미지
private readonly Image[] PlayerColorsLeft = new Image[]
{
Properties.Resources.RED_LEFT,
Properties.Resources.ORANGE_LEFT,
Properties.Resources.YELLOW_LEFT,
Properties.Resources.GREEN_LEFT,
Properties.Resources.BLUE_LEFT,
Properties.Resources.NABY_LEFT,
Properties.Resources.PURPLE_LEFT,
Properties.Resources.WHITE_LEFT
};
private readonly Image[] PlayerColorsRight = new Image[]
{
Properties.Resources.RED,
Properties.Resources.ORANGE,
Properties.Resources.YELLOW,
Properties.Resources.GREEN,
Properties.Resources.BLUE,
Properties.Resources.NABY,
Properties.Resources.PURPLE,
Properties.Resources.WHITE
};
private void UpdateOrCreateCharacter(PlayerState player)
{
listBox2.Items.Add($"[UPDATE] {player.Nickname} -> ({player.x}, {player.y})");
var ground = floors.FirstOrDefault();
int groundY = ground?.Top ?? this.ClientSize.Height - 40; // 바닥 두께 빼기
int chaHeight = 50; // 캐릭터 높이
int renderY = groundY - (int)player.y - chaHeight;
int renderX = (int)player.x;
// 방향에 대한 변수
int direction = 0; // 0: 정지, -1: 왼쪽, +1: 오른쪽
// prevX의 키와 값을 가지고, 현재(player.x)와 비교해 변수를 변경
if(prevX.TryGetValue(player.Id, out var prev))
{
if (player.x < prev) direction = -1;
else if(player.x > prev) direction = 1;
}
// 현재 x좌표 저장
prevX[player.Id] = player.x;
if (players.TryGetValue(player.Id, out var pb))
{
// Id(Key)에 맞는 pb(Value)의 위치를 변경하고,
pb.Location = new Point(renderX, renderY);
// 방향 변수에 따라 이미지 변경
if(direction != 0)
{
pb.Image = (direction == -1)
// player.Id는 1부터 시작하므로 0부터 7까지 모두 사용하고, 8부터 다시 0부터 시작 가능함
? PlayerColorsLeft[(player.Id - 1) % PlayerColorsLeft.Length]
: PlayerColorsRight[(player.Id - 1) % PlayerColorsRight.Length];
}
}
else
{
pb = new PictureBox
{
Name = player.Nickname,
Size = new Size(chaHeight, chaHeight),
SizeMode = PictureBoxSizeMode.StretchImage,
Image = PlayerColorsRight[(player.Id - 1) % PlayerColorsRight.Length],
Location = new Point(renderX, renderY),
BackColor = Color.Transparent
};
listBox2.Items.Add($"STATE_LONG: {player.Nickname} ({player.x}, {player.y})");
Controls.Add(pb);
pb.BringToFront();
players[player.Id] = pb;
}
}
바닥을 만들고, 캐릭터들을 그 위에 고정
1. CLIENT
<GameForm.cs>
// 맵 - 바닥
private List<PictureBox> floors = new List<PictureBox>();
private PictureBox? mainFloor;
private void InitFloor()
{
// 메인 바닥 생성
mainFloor = new PictureBox
{
Name = "Floor_Main",
BackColor = Color.SaddleBrown,
Height = 40,
Width = this.ClientSize.Width,
Top = this.ClientSize.Height - 40,
Left = 0
};
// 폼 크기가 바뀌면 자동으로 폭 조정하고 컨트롤에 추가
mainFloor.Anchor = AnchorStyles.Left | AnchorStyles.Right | AnchorStyles.Bottom;
this.Controls.Add(mainFloor);
// PictureBox List인 floors에도 추가시킴
floors.Add(mainFloor);
}
private void UpdateOrCreateCharacter(PlayerState player)
{
listBox2.Items.Add($"[UPDATE] {player.Nickname} -> ({player.x}, {player.y})");
// 바닥(ground)은 가장 첫번째 것을 사용
var ground = floors.FirstOrDefault();
// groundY 는 바닥의 위치
// ground가 null이 아니라면 ground.Top을 사용
// ground.Top이 null이 아니라면 그대로 사용하고, null이라면 ClientSize.Height - 40 값을 사용
int groundY = ground?.Top ?? this.ClientSize.Height - 40;
int chaHeight = 50; // 캐릭터 높이
// render는 서버에서는 y 좌표가 커질수록 위로 올라가는 반면
// 클라이언트는 아래로 내려가기 때문에 이를 반전해주기 위한 좌표계산임
// x는 같고, y만 땅 높이에서 플레이어의 서버 좌표 및 높이를 빼서 사용함
int renderY = groundY - (int)player.y - chaHeight;
int renderX = (int)player.x;
int direction = 0; // 0: 정지, -1: 왼쪽, +1: 오른쪽
if(prevX.TryGetValue(player.Id, out var prev))
{
if (player.x < prev) direction = -1;
else if(player.x > prev) direction = 1;
}
// 현재 좌표 저장
prevX[player.Id] = player.x;
if (players.TryGetValue(player.Id, out var pb))
{
pb.Location = new Point(renderX, renderY);
// 방향에 따라 이미지 변경
if(direction != 0)
{
pb.Image = (direction == -1)
? PlayerColorsLeft[(player.Id - 1) % PlayerColorsLeft.Length]
: PlayerColorsRight[(player.Id - 1) % PlayerColorsRight.Length];
}
}
else
{
pb = new PictureBox
{
Name = player.Nickname,
Size = new Size(chaHeight, chaHeight),
SizeMode = PictureBoxSizeMode.StretchImage,
Image = PlayerColorsRight[(player.Id - 1) % PlayerColorsRight.Length],
Location = new Point(renderX, renderY),
BackColor = Color.Transparent
};
listBox2.Items.Add($"STATE_LONG: {player.Nickname} ({player.x}, {player.y})");
Controls.Add(pb);
pb.BringToFront();
players[player.Id] = pb;
}
}
실행영상
동영상 서비스가 종료되어 해당 콘텐츠를 재생할 수 없습니다.
'LMS 7 > 개발일지' 카테고리의 다른 글
| 25.10.20 개발일지 / C# 네트워크 (0) | 2025.11.13 |
|---|---|
| 25.10.17 개발일지 / C# 명코파크 (5일차) (0) | 2025.11.13 |
| 25.10.15 개발일지 / C# 명코파크 (3일차) (0) | 2025.11.13 |
| 25.10.14 개발일지 / C# 명코파크 (2일차) (0) | 2025.11.13 |
| 25.10.13 개발일지 / C# 명코파크 (1일차) (0) | 2025.11.13 |