使用的软件及语言:
- Visual Studio
- C#
前言
最近有需求要设计一个多人在线聊天的小游戏(类VRChat),由于此前有一点点的Unity经验,看用Unity鼓捣了许久。最开始是直接用Photon设计了一个小Demo,但是国内连接Photon服务器延迟比较高(200ms上下),再加上免费版本只能最高20人在线,就决定自己尝试开发服务端,所以有了这系列文章(开坑 雾。
这算不上是教程类的文章,算是记录学习过程的文章,也希望能通过写博客来复盘程序结构。
作为系列开篇作,本文主要强调使用C#设计一个控制台的类坦克大战小Game,并为多人运动制作一个简单的同步服务端。需要注意的是,本篇文章内实现的服务端不涉及帧同步、状态同步或Protobuf,仅使用Json通信并对用户消息进行转发(由于服务端特性,该端无法保证安全性,将在后续的状态同步和帧同步服务端中解决)。

1.客户端实现
1.1目标
这是一个脑内渲染坦克世界游戏,由于是在Console中运行,我打算用字符来绘制地图元素和玩家。同时使用不同颜色标记当前玩家和其他玩家,移动和开火功能自然不必多说。后续打算也会在这个客户端的基础上开发使用不同同步方式的服务端以学习不同同步方式的服务端开发。

1.2基本类型的声明
我们需要先定义一些核心游戏对象,比如Player(玩家)、Map(地图)、Bull(炮弹)
1.2.1ConsoleRenderer.cs
这个类实际上是后期重构的时候新定义的,原因是在主循环以及其他函数中会频繁调用SetCursorPosition和Write来定点绘制字符,绘制逻辑和执行逻辑穿插在一起,即增高了耦合度也导致整体程序显得臃肿,故写了一个专门负责绘制的类。先将每一帧的更改缓存进buffer中,然后在主循环结束后统一绘制。
namespace TankWar
{
internal class ConsoleRenderer
{
private readonly char[,] _buffer; // 渲染缓冲区
private readonly ConsoleColor[,] _foregroundColors; // 前景色缓冲区
public int Width { get; } // 渲染宽度
public int Height { get; } // 渲染高度
public ConsoleRenderer(int width, int height)
{
Width = width;
Height = height;
_buffer = new char[width, height];
_foregroundColors = new ConsoleColor[width, height];
Clean();
}
/// <summary>
/// 初始化缓冲区
/// </summary>
public void Clean()
{
for (int y = 0; y < Height; y++)
{
for (int x = 0; x < Width; x++)
{
_buffer[x, y] = ' ';
_foregroundColors[x, y] = ConsoleColor.Gray;
}
}
}
/// <summary>
/// 字符绘制
/// </summary>
/// <param name="x"></param>
/// <param name="y"></param>
/// <param name="c"></param>
/// <param name="color"></param>
/// <param name="narrowdown"></param>
public void SetPixel(int x, int y, char c, ConsoleColor color = ConsoleColor.Gray,int narrowdown = 0)
{
if (x >= 0 && x < Width - narrowdown && y >= 0 && y < Height - narrowdown)
{
_buffer[x, y] = c;
_foregroundColors[x, y] = color;
}
}
/// <summary>
/// 字符串绘制
/// </summary>
/// <param name="x"></param>
/// <param name="y"></param>
/// <param name="text"></param>
/// <param name="color"></param>
/// <param name="narrowdown"></param>
public void DrawString(int x, int y, string text, ConsoleColor color = ConsoleColor.Gray,int narrowdown = 0)
{
if (y < 0 || y >= Height - narrowdown) return;
for (int i = 0; i < text.Length; i++)
{
if (x + i >= Width - narrowdown) break;
SetPixel(x + i, y, text[i], color);
}
}
/// <summary>
/// 一次性绘制缓冲区内容到控制台
/// </summary>
public void Render()
{
Console.SetCursorPosition(0, 0);
for (int y = 0; y < Height; y++)
{
for (int x = 0; x < Width; x++)
{
Console.ForegroundColor = _foregroundColors[x, y];
Console.Write(_buffer[x, y]);
}
Console.WriteLine();
}
Console.ResetColor();
}
}
}
1.2.2Map.cs
namespace TankWar
{
internal class Map
{
public int Width { get; set; } // 地图宽度
public int Height { get; set; } // 地图高度
public int SpawnAX { get; set; } // A点出生X坐标
public int SpawnAY { get; set; } // A点出生Y坐标
public int SpawnBX { get; set; } // B点出生X坐标
public int SpawnBY { get; set; } // B点出生Y坐标
public char WallSymbol { get; set; } // 表示墙体的字符
public char GroundSymbol { get; set; } // 表示地面的字符
public char[,] Buffer { get; set; } // 地图缓冲区
public Map(int width,int height,int spawnax,int spawnay,int spawnbx,int spawnby,char wall_symbol,char ground_symbol)
{
Width = width;
Height = height;
SpawnAX = spawnax;
SpawnAY = spawnay;
SpawnBX = spawnbx;
SpawnBY = spawnby;
WallSymbol = wall_symbol;
GroundSymbol = ground_symbol;
Buffer = new char[width, height];
}
/// <summary>
/// 地图绘制
/// </summary>
/// <param name="renderer"></param>
public void DrawMap(ConsoleRenderer renderer)
{
for (int y = 0; y < Height; y++)
{
for (int x = 0; x < Width; x++)
{
if (x == 0 || y == 0 || x == Width - 1 || y == Height - 1)
{
Buffer[x, y] = WallSymbol;
renderer.SetPixel(x, y,WallSymbol);
}
else
{
Buffer[x, y] = GroundSymbol;
renderer.SetPixel(x, y, GroundSymbol);
}
}
Console.WriteLine();
}
}
/// <summary>
/// 随机地图内位置
/// </summary>
/// <returns></returns>
public (int x, int y) GetRandomPosition()
{
Random rd = new Random();
int x = rd.Next(2,this.Width - 1);
int y = rd.Next(2,this.Height - 1);
return (x,y);
}
}
}
1.2.3Player.cs
在Player类中,比较麻烦的是DrawPlayer方法,需要在绘制当前位置字符的同时擦除上一帧的图像
至于为什么不用ConsoleRenderer来绘制Explosion的爆炸字符,主要是因为ConsoleRenderer不是实时的,它是在先缓存该帧的所有更改,然后在主循环结束后统一绘制,会出现一不流畅的问题
namespace TankWar
{
internal class Player
{
public enum Direction
{
up,
down,
right,
left,
}
public string Name { get; set; } = string.Empty;
public int X { get; set; }
public int Y { get; set; }
public int MoveSpeed { get; set; }
public int Hp { get; set; }
public Direction Dir { get; set; }
public bool IsAlive { get; set; } = true;
public string UUID { get; }
public int LastX { get; set; }
public int LastY { get; set; }
public Player(string name,int x,int y,int movespeed,int hp,Direction dir)
{
UUID = Guid.NewGuid().ToString("N");
Name = name;
X = x;
Y = y;
Hp = hp;
Dir = dir;
MoveSpeed = movespeed;
LastX = x;
LastY = y;
}
public void Move(int movex = 0,int movey = 0)
{
X += movex * MoveSpeed;
Y += movey * MoveSpeed;
}
public void DrawPlayer(Map map,ConsoleRenderer renderer,bool isme = false)
{
renderer.SetPixel(LastX, LastY,' ');
renderer.DrawString(LastX, LastY + 1, new string(' ', Name.Length), narrowdown: 1);
ConsoleColor color = isme ? ConsoleColor.Green : ConsoleColor.Red;
char symbol = this.Dir switch
{
Direction.up => '┴',
Direction.down => '┬',
Direction.left => '┤',
Direction.right => '├',
_ => 'X'
};
renderer.SetPixel(X, Y, symbol, color);
renderer.DrawString(X, Y + 1, Name, color,narrowdown:1);
LastX = X;
LastY = Y;
}
public void Hit(Map map,Bullet bullet,ConsoleRenderer renderer)
{
this.Hp -= bullet.Damage;
if (this.Hp <= 0)
{
Die(map,renderer);
}
}
public void Fire(List<Bullet> bullets)
{
int bulletX = X;
int bulletY = Y;
switch (Dir)
{
case Player.Direction.up:
bulletY -= 1;
break;
case Player.Direction.down:
bulletY += 1;
break;
case Player.Direction.left:
bulletX -= 1;
break;
case Player.Direction.right:
bulletX += 1;
break;
}
Bullet bullet = new Bullet(bulletX, bulletY, 1, '*', this.Dir);
bullets.Add(bullet);
}
private void Die(Map map,ConsoleRenderer renderer)
{
this.IsAlive = false;
renderer.SetPixel(X, Y, map.GroundSymbol);
renderer.DrawString(X, Y + 1, new string(' ', Name.Length));
Explosion(this.X, this.Y,renderer);
}
private void Explosion(int x, int y,ConsoleRenderer renderer)
{
Console.ForegroundColor = ConsoleColor.Yellow;
string[][] frames = new string[][]
{
new string[]
{
" * ",
" *** ",
" * "
},
new string[]
{
" @@@ ",
" @@@@@ ",
" @@@ "
},
new string[]
{
" ##### ",
"#######",
" ##### "
},
new string[]
{
" +++ ",
"+++++++",
" +++ "
},
new string[]
{
" + ",
" +++++ ",
" + "
},
};
foreach (var frame in frames)
{
for (int i = 0; i < frame.Length; i++)
{
Console.SetCursorPosition(x - frame[i].Length / 2, y - 1 + i);
Console.Write(frame[i]);
}
Thread.Sleep(50);
for (int i = 0; i < frame.Length; i++)
{
Console.SetCursorPosition(x - frame[i].Length / 2, y - 1 + i);
Console.Write(new string(' ', frame[i].Length));
}
}
Console.ResetColor();
}
}
}
1.2.4Bullet.cs
namespace TankWar
{
internal class Bullet
{
public int X { get; set; } // 炮弹X坐标
public int Y { get; set; } // 炮弹Y坐标
public int Damage { get; set; } // 炮弹伤害
public char Symbol { get; set; } // 炮弹字符
public Player.Direction Dir { get; set; } // 炮弹方向
public bool IsAlive { get; set; } = true; // 炮弹是否存活
public Bullet(int x,int y,int damage,char symbol,Player.Direction dir)
{
X = x;
Y = y;
Damage = damage;
Symbol = symbol;
Dir = dir;
}
/// <summary>
/// 炮弹移动
/// </summary>
/// <param name="renderer"></param>
/// <param name="map"></param>
/// <param name="allp"></param>
public void Move(ConsoleRenderer renderer,Map map, List<Player> allp)
{
renderer.SetPixel(X, Y,' ');
switch (Dir)
{
case Player.Direction.up:
Y--;
break;
case Player.Direction.down:
Y++;
break;
case Player.Direction.left:
X--;
break;
case Player.Direction.right:
X++;
break;
}
if (X <= 0 || Y <= 0 || X >= map.Width - 1 || Y >= map.Height - 1)
{
IsAlive = false;
return;
}
foreach (var p in allp)
{
if (p.IsAlive && p.X == X && p.Y == Y)
{
p.Hit(map, this, renderer);
IsAlive = false;
return;
}
}
if (IsAlive)
{
renderer.SetPixel(X, Y, Symbol);
}
}
}
}
1.3网络通信类
这里不使用Protobuf来打包通信数据,此处为了演示作用和轻量化的目的直接序列化Json来打包数据,在实际中还是推荐使用Protobuf或至少将json加密压缩后进行传输。
1.3.1PlayerPositionMessage.cs
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Linq;
namespace TankWar.TcpMessage
{
internal class PlayerPositionMessage
{
public string? PlayerName { get; set; } // 玩家名称
public int PlayerX { get; set; } // 玩家X坐标
public int PlayerY { get; set; } // 玩家Y坐标
public int PlayerHp { get; set; } // 玩家血量
public Player.Direction Dir { get; set; } // 玩家方向
public bool Fire { get; set; } // 是否开火
public PlayerPositionMessage(string playername,int playerx,int playery,int playerhp,Player.Direction dir,bool fire = false)
{
PlayerName = playername;
PlayerX = playerx;
PlayerY = playery;
PlayerHp = playerhp;
Dir = dir;
Fire = fire;
}
}
}
1.3.2NetWork.cs
using Newtonsoft.Json;
using System.Collections.Concurrent;
using System.Net.Sockets;
using System.Text;
using TankWar.TcpMessage;
namespace TankWar
{
internal class NetWork
{
private TcpClient? Client; // TCP客户端
private StreamReader? Reader; // 读取流
private StreamWriter? Writer; // 写入流
private Thread? ReceiveThread; // 接收线程
private string ServerIp; // 服务器IP
private int ServerPort; // 服务器端口
public ConcurrentQueue<PlayerPositionMessage> ReceivedQueue = new ConcurrentQueue<PlayerPositionMessage>(); // 接收队列
public bool IsConnect { get; set; }
public NetWork(string serverip,int serverport)
{
ServerIp = serverip;
ServerPort = serverport;
}
public bool Connect()
{
try
{
Client = new TcpClient();
Client.Connect(ServerIp, ServerPort);
var stream = Client.GetStream();
var utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
Reader = new StreamReader(stream, utf8NoBom);
Writer = new StreamWriter(stream, utf8NoBom) { AutoFlush = true };
IsConnect = true;
ReceiveThread = new Thread(ReceiveLoop);
ReceiveThread.IsBackground = true;
ReceiveThread.Start();
return true;
}
catch (Exception ex)
{
Console.WriteLine($"Connect server error: {ex.Message}");
return false;
}
}
public void SendPosition(PlayerPositionMessage msg)
{
if (!IsConnect || Writer == null) return;
try
{
string json = JsonConvert.SerializeObject(msg);
Writer.WriteLine(json);
}
catch
{
Console.WriteLine("Send data error");
Disconnect();
}
}
private void ReceiveLoop()
{
try
{
while (IsConnect && Reader != null)
{
string? line = Reader.ReadLine();
if (string.IsNullOrEmpty(line)) break;
var msg = JsonConvert.DeserializeObject<PlayerPositionMessage>(line);
if (msg != null)
{
ReceivedQueue.Enqueue(msg);
}
}
}
catch { }
finally
{
Disconnect();
Console.WriteLine("DeserializeObject data error");
}
}
public void Disconnect()
{
//Console.WriteLine("Remote server return data error");
IsConnect = false;
Reader?.Dispose();
Writer?.Dispose();
Client?.Close();
Client?.Dispose();
}
}
}
1.4主循环
using TankWar.TcpMessage;
namespace TankWar
{
class TankWar
{
static Map map = new Map(40, 20, 2, 2, 32, 15, '#', ' '); // 创建地图对象
static ConsoleRenderer renderer = new ConsoleRenderer(map.Width,map.Height); // 创建渲染器对象
static List<Player> allPlayers = new List<Player>(); // 玩家列表
static List<Bullet> allBullets = new List<Bullet>(); // 炮弹列表
static Player? myPlayer; // 本地玩家对
static NetWork client; // 网络客户端
static void Main(string[] args)
{
Console.CursorVisible = false; // 关闭控制台光标显示
Console.WriteLine("You Player Name:");
string Name = Console.ReadLine();
Console.Clear();
client = new NetWork("127.0.0.1", 8888);
if (!client.Connect())
{
Console.ReadKey();
return;
}
(int x,int y) RandomPosition = map.GetRandomPosition(); // 随机出生位置
myPlayer = new Player(Name, RandomPosition.x, RandomPosition.y, 1, 1, Player.Direction.right);
allPlayers.Add(myPlayer);
map.DrawMap(renderer); // 绘制地图
myPlayer.DrawPlayer(map,renderer, true); // 绘制本地玩家
client.SendPosition(new PlayerPositionMessage(
playername: myPlayer.Name,
playerx: myPlayer.X,
playery: myPlayer.Y,
playerhp: myPlayer.Hp,
dir: myPlayer.Dir
));
while (client.IsConnect)
{
while (client.ReceivedQueue.TryDequeue(out var networkmsg)) // 处理接收到的网络消息
{
UpdateRemotePlayer(networkmsg); // 更新远程玩家状态
}
// 本地玩家输入处理
ConsoleKeyInfo? keyInfo = null;
if (Console.KeyAvailable)
{
keyInfo = Console.ReadKey(true);
}
if (keyInfo.HasValue && myPlayer.IsAlive)
{
int oldX = myPlayer.X;
int oldY = myPlayer.Y;
switch (keyInfo.Value.Key)
{
case ConsoleKey.W:
if (myPlayer.Y > 1) { myPlayer.Move(movey: -1); myPlayer.Dir = Player.Direction.up; }
break;
case ConsoleKey.S:
if (myPlayer.Y < map.Height - 2) { myPlayer.Move(movey: 1); myPlayer.Dir = Player.Direction.down; }
break;
case ConsoleKey.A:
if (myPlayer.X > 1) { myPlayer.Move(movex: -1); myPlayer.Dir = Player.Direction.left; }
break;
case ConsoleKey.D:
if (myPlayer.X < map.Width - 2) { myPlayer.Move(movex: 1); myPlayer.Dir = Player.Direction.right; }
break;
case ConsoleKey.Spacebar:
myPlayer.Fire(allBullets);
client.SendPosition(new PlayerPositionMessage(
playername: myPlayer.Name,
playerx: myPlayer.X,
playery: myPlayer.Y,
playerhp: myPlayer.Hp,
dir: myPlayer.Dir,
fire: true
));
break;
case ConsoleKey.E:
myPlayer.Hit(map, new Bullet(1, 1, 1, '*', Player.Direction.left),renderer);
break;
}
if (myPlayer.X != oldX || myPlayer.Y != oldY)
{
client.SendPosition(new PlayerPositionMessage(
playername: myPlayer.Name,
playerx: myPlayer.X,
playery: myPlayer.Y,
playerhp: myPlayer.Hp,
dir: myPlayer.Dir
));
}
}
if (myPlayer.IsAlive)
{
myPlayer.DrawPlayer(map,renderer, true);
}
else
{
// Game Over
Console.Clear();
Console.WriteLine("Game Over");
Console.WriteLine("Press any key to exit...");
Console.ReadKey();
return;
}
foreach (Bullet b in allBullets)
{
if (b.IsAlive) b.Move(renderer,map, allPlayers);
}
allBullets.RemoveAll(b => !b.IsAlive);
allPlayers.RemoveAll(p => !p.IsAlive);
renderer.Render();
Thread.Sleep(50);
}
}
/// <summary>
/// 同步远程玩家状态
/// </summary>
/// <param name="nwm"></param>
static void UpdateRemotePlayer(PlayerPositionMessage nwm)
{
if (nwm.PlayerName == myPlayer?.Name) return;
Player? p = allPlayers.Find(p => p.Name == nwm.PlayerName);
if (p == null)
{
p = new Player(nwm.PlayerName, nwm.PlayerX, nwm.PlayerY, 1, nwm.PlayerHp, nwm.Dir);
allPlayers.Add(p);
p.DrawPlayer(map,renderer);
client.SendPosition(new PlayerPositionMessage(
playername: myPlayer.Name,
playerx: myPlayer.X,
playery: myPlayer.Y,
playerhp: myPlayer.Hp,
dir: myPlayer.Dir
));
}
else
{
p.X = nwm.PlayerX;
p.Y = nwm.PlayerY;
p.Dir = nwm.Dir;
p.Hp = nwm.PlayerHp;
if (nwm.Fire)
{
p.Fire(allBullets);
}
p.DrawPlayer(map,renderer);
}
}
}
}
2.服务端实现
其实转发服务端需要负责的东西非常的少,只需要新建一个clients列表(此处使用ConcurrentDictionary)然后遍历clients将接收到的客户端消息转发给每个client即可
using System.Collections.Concurrent;
using System.Net;
using System.Net.Sockets;
using System.Text;
namespace TankWarServer
{
internal class Program
{
private static readonly ConcurrentDictionary<TcpClient,bool> _clients = new();
private const string IpAddress = "127.0.0.1";
private const int Port = 8888;
static async Task Main(string[] args)
{
var listener = new TcpListener(IPAddress.Parse(IpAddress), Port);
listener.Start();
Console.WriteLine($"服务器启动在 {IpAddress}:{Port}");
while (true)
{
var client = await listener.AcceptTcpClientAsync();
_ = Task.Run(() => HandleClient(client));
}
}
static async Task HandleClient(TcpClient client)
{
Console.WriteLine($"新客户端连接: {client.Client.RemoteEndPoint}");
_clients.TryAdd(client,true);
try
{
var stream = client.GetStream();
var buffer = new byte[1024];
int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
if (bytesRead == 0) return;
var joinMessage = Encoding.UTF8.GetString(buffer, 0, bytesRead);
Console.WriteLine($"收到 Join 消息: {joinMessage.Trim()}");
BroadcastToAll(joinMessage);
while (true)
{
bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length);
if (bytesRead == 0) break;
var message = Encoding.UTF8.GetString(buffer, 0, bytesRead);
Console.WriteLine($"转发消息: {message.Trim()}");
BroadcastToOthers(message, client);
}
}
catch (Exception ex)
{
Console.WriteLine($"客户端处理异常: {ex.Message}");
}
finally
{
_clients.TryRemove(client,out _);
Console.WriteLine($"客户端断开: {client.Client.RemoteEndPoint}");
client.Close();
}
}
static void BroadcastToAll(string message)
{
var messageBytes = Encoding.UTF8.GetBytes(message);
foreach (var client in _clients)
{
try
{
if (client.Key.Connected)
{
_ = client.Key.GetStream().WriteAsync(messageBytes, 0, messageBytes.Length);
}
}
catch
{
}
}
}
static void BroadcastToOthers(string message, TcpClient sender)
{
var messageBytes = Encoding.UTF8.GetBytes(message);
foreach (var client in _clients)
{
if (client.Key == sender) continue;
try
{
if (client.Key.Connected)
{
_ = client.Key.GetStream().WriteAsync(messageBytes, 0, messageBytes.Length);
}
}
catch
{
}
}
}
}
}
待续...



Comments NOTHING