我不是第一个在 Godot 里折腾 C# 网络通信的人,如果你搜过相关关键词,大概也发现了:官方文档里的网络示例几乎全是 GDScript,社区里问 C# 版本的人一抓一大把,回答却经常是“你可以把 GDScript 翻译成 C#”。这句话听起来简单,真翻译起来全是坑——异步委托、线程切换、Mono 对象生命周期、信号回调在主线程还是子线程执行,这些问题没人给你划重点。
这份笔记是我的 Godot 引擎基础学习系列第 10 篇,主题锁定在 C# 环境下实现 TCP 通信的完整链路:服务端、客户端、粘包处理、跨线程回传、断线重连,以及打包发布后连不上服务器这类诡异问题。适合那些已经能用 Godot 摆弄场景、挂脚本,但对 C# 网络编程还停留在“大概知道”阶段的开发者。我会尽量把每一步的“为什么”讲清楚,而不只是贴代码让你抄。
如果你只是想把 Godot 当作一个可视化前端,对接自己用 C# 写的上位机或后端服务,这篇笔记会很有用。
1. 写这篇笔记的动机:Godot C# 的网络开发为何如此别扭
1.1 为什么从第 10 篇才开始写网络
说句实在话,网络通信并不是 Godot 游戏开发最优先要学的东西。做单机游戏、做工具型应用、做 UI 原型,都碰不到 Socket。但我个人走到第 10 篇时,基本玩法、信号系统、场景树、资源加载这些基础都过了一遍,再往后想深入应用场景,网络就绕不开了——尤其是你想用 Godot 做“真实项目”的时候。
打个比方:单机游戏是你在自己家里做饭,锅碗瓢盆都在手边,不需要跟任何人沟通。但一旦涉及多人对战、远程配置下发、数据上报,甚至只是把一个设备上的运行状态实时同步到另一个设备上,你就得跟“网络”这个外部世界打交道。Godot 对 GDScript 的网络封装看似很友好,到了 C# 这边一换语言,体验直接打折扣。
这段经历也解释了一个我在搜索时频繁看到的现象:几乎每个 C# 开发者遇到 Godot 网络问题,第一反应都是去查 C# 原生的 TcpClient、TcpListener 能不能直接用。 答案是能,但用起来比在控制台程序里多了不少讲究。
1.2 C# 版 Godot 网络的“文档荒漠”与破局思路
我自己踩坑时翻了官方文档、Godot 源码、GitHub issues、Reddit 帖子,发现一个规律:官方推荐 C# 项目使用 Godot 封装好的 ENet 或 WebSocket,但文档给的示例基本以 GDScript 为主,C# 很少有人系统整理。
而社区里的 C# 教程,更多的是教你用 System.Net.Sockets 直接造轮子,因为这样最灵活——你可以对接任何自定义协议,不依赖 Godot 的 high-level multiplayer 机制。
我的建议是:如果你是要做游戏里的联机功能,优先学习 Godot 官方的高层网络 API;但如果你是想让 Godot 当一个客户端,去连你已有的 C# 服务端(比如上位机、后端服务、别的设备的 Socket 程序),那就直接走底层 Socket,反而少走弯路。 这篇笔记的示例就按第二种场景来。
1.3 我采用的整体架构:Godot 客户端 + C# 服务端
为了让结构清晰,本篇做一个最简单但完整的演示:
- 服务端:用普通的 C# 控制台程序写,监听某个端口,收到客户端消息后回一条确认。
- 客户端:Godot 4.x .NET 版,里面写一个 C# 脚本挂在 Node 上,启动时连接服务端,界面层用 Label 显示连接状态,用一个 LineEdit 输入要发送的消息。
- 通信协议:为了讲清楚“粘包”问题,我故意采用自定义格式——每个消息前 4 字节表示消息体长度,后面跟着 UTF-8 编码的消息体。
这套结构不涉及 ENet,完全用 System.Net.Sockets,目标是让你真正理解底层机制。如果你后续想对接真实设备或已有服务,这些代码可以直接当模板改。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与项目结构:.NET 版 Godot 的最小可行配置
2.1 用官方模板创建 C# 工程
Godot 4.x 从某个版本开始把 .NET 支持整合得很好,但是有一个细节你必须注意:不要在普通版 Godot 里新建 C# 脚本,那些选项根本不存在。 你需要下载 .NET 版(Godot 官方命名为 .NET 版本,Windows 下通常文件名带 _mono 或 .net)。
创建项目的步骤很简单:
- 打开 Godot .NET 版。
- 新建项目时,渲染器可以选 Forward Plus 或 Mobile,跟网络通信无关。
- 项目创建完成后,在
Project > Project Settings > General > Dotnet里可以看到项目相关的 .NET 配置。 - 打开项目后,直接往场景里挂 C# 脚本会提示你需要先生成解决方案文件——Godot 会在项目目录下自动生成一个
.sln文件,只要你在 “Build” 菜单里点一下 “Build Solution” 或者用 Visual Studio 打开编译。
这里有个容易踩坑的点:如果你用 Visual Studio 打开 .sln 编译,别用太老的 .NET SDK。Godot 4 的 C# 版基于 .NET 6 以上,建议直接装 .NET 8 SDK。用 dotnet --version 能直接查到本机版本。
2.2 程序集、脚本类与节点的挂接方式
在 Godot 里,C# 脚本本身是一个继承自 Node 的类,编译好后会生成一个和类名同名的资源。你把它拖拽到节点上即可。
下面是一个极小的网络客户端脚本框架:
csharp复制using Godot;
using System;
using System.Net.Sockets;
public partial class TcpClientNode : Node
{
private TcpClient _client;
private NetworkStream _stream;
public override void _Ready()
{
GD.Print("TcpClientNode ready");
}
}
这个脚本挂到任意节点上后,_Ready 会在场景加载完成后执行。但注意一个关键点:_Ready 是在 Godot 主线程上执行的。C# 脚本里的事件委托、异步回调则不一定。这是贯穿整篇笔记的核心问题,后面我会专门开一节讲线程桥接。
2.3 为什么 C# 版里应该多用 GodotObject 的调度机制
在 GDScript 里,你调用一个方法、改一个节点属性,引擎会自动帮你处理线程问题吗?不会,GDScript 同样不能跨线程访问场景树。但 GDScript 的协程和信号写起来方便,大家潜移默化地避开了坑。C# 这边一上来就是 async/await、Task.Run,很容易写出一个后台线程里直接改 Label.Text 的代码,然后就等崩溃吧。
所以我在项目里约定了一条纪律:所有直接操作 Godot 节点的代码,必须回到主线程执行。 方法有三种:
CallDeferred:把方法调用推迟到当前帧处理的合适时机。GodotObject.SynchronizationContext:配合异步方法把上下文切回主线程。- 自建线程安全队列:子线程往队列丢数据,主线程每帧消费。
在写正式代码前,先把这条纪律刻在脑子里,后面很多问题都能避免。
3. 服务端先行:TcpListener 接收消息与粘包处理
3.1 一个最简单的 C# 控制台服务端
虽然 Godot 是主角,但为了调试,必须先有一个可控的服务端。我不建议直接用网络调试助手,因为调试助手发的是裸数据,没法模拟自定义协议。我更推荐用 Visual Studio 新建一个 C# 控制台项目,几行代码搞定。
服务端核心代码:
csharp复制using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;
class Program
{
static async Task Main(string[] args)
{
var listener = new TcpListener(IPAddress.Any, 6000);
listener.Start();
Console.WriteLine("Server started on port 6000");
while (true)
{
TcpClient client = await listener.AcceptTcpClientAsync();
_ = HandleClientAsync(client);
}
}
static async Task HandleClientAsync(TcpClient client)
{
Console.WriteLine($"Client connected: {client.Client.RemoteEndPoint}");
NetworkStream stream = client.GetStream();
byte[] buffer = new byte[4];
try
{
while (true)
{
int read = await stream.ReadAsync(buffer, 0, 4);
if (read == 0) break;
if (read < 4) { /* log error */ break; }
int bodyLength = IPAddress.NetworkToHostOrder(BitConverter.ToInt32(buffer, 0));
byte[] body = new byte[bodyLength];
int received = 0;
while (received < bodyLength)
{
int n = await stream.ReadAsync(body, received, bodyLength - received);
if (n == 0) throw new IOException("Connection closed");
received += n;
}
string message = Encoding.UTF8.GetString(body);
Console.WriteLine($"Received: {message}");
byte[] ack = Encoding.UTF8.GetBytes("ack:" + message);
byte[] header = BitConverter.GetBytes(IPAddress.HostToNetworkOrder(ack.Length));
await stream.WriteAsync(header, 0, 4);
await stream.WriteAsync(ack, 0, ack.Length);
}
}
catch (Exception ex)
{
Console.WriteLine($"Error: {ex.Message}");
}
finally
{
client.Close();
}
}
}
代码逻辑很简单:监听 6000 端口,每收到一个连接就开一个异步任务处理。每条消息先读 4 字节头,解析出消息体长度,再按长度读完整消息。这个实现等价于很多游戏或上下位机通信里约定的“长度前缀协议”。
注意 IPAddress.HostToNetworkOrder 和 IPAddress.NetworkToHostOrder,它们负责处理大小端问题。如果你要对接的服务端已经用大端序,你必须做这个转换,否则长度解析会错得离谱。
3.2 粘包问题为什么必须自己处理
关于粘包,底层 TCP 是字节流,没有“消息边界”的概念。你 Send 两次,对端可能一次性收到两段数据,也可能分三次收到,这取决于网络拥塞和缓冲区大小。
处理方式一般有两种:一种是定长消息,每条消息都固定 N 字节,缺点是短消息浪费空间,长消息要分片;另一种就是本文用的长度前缀法,也叫 TLN(Type-Length-Value 的简化版),前面 4 字节是长度,后面跟消息体。
这种格式非常像你在 C# 里经常遇见的二进制协议——你用 BinaryReader 读一个 Int32 再读字节数组,本质一回事。用 Godot 的 StreamPeerBuffer 也能做类似处理,但直接用 C# 的 BitConverter 更顺手。
我在服务端故意做了“每次只读 4 字节,再读 bodyLength 字节”的循环,这就保证了即使客户端一次性发来多条消息,服务端也能一条一条切开。这一点很关键,很多新手在这里栽过跟头。
3.3 服务端压力测试的关键指标
你可以先把服务端跑起来,用任何一个 TCP 客户端工具测一下。我习惯用自己写的小工具做压力测试:
- 每秒发送 1000 条短消息
- 每条消息长度随机在 1~1024 字节之间
- 观察服务端接收的字节数是否正确
如果服务端解析出的消息数和发送端一致,说明粘包处理逻辑没问题。这一步不要跳过,因为后面 Godot 客户端写好后,如果服务端本身有 bug,你会分不清是客户端还是服务端的责任。
4. 客户端接入:TcpClient 连接管理与异步读写
4.1 在 Godot 中封装一个网络服务类
在 Godot 的 C# 脚本里,我习惯把网络逻辑和表现逻辑分开:一个 NetworkService 类只管 Socket,负责连接、收发消息;节点层只负责调用 NetworkService 并在 UI 上显示状态。这样如果你以后想在多个场景复用网络连接,就不需要把 Socket 代码复制粘贴。
NetworkService 的基础结构:
csharp复制using System;
using System.Net.Sockets;
using System.Text;
using System.Threading.Tasks;
public class NetworkService
{
private TcpClient _client;
private NetworkStream _stream;
private bool _isConnected;
public bool IsConnected => _isConnected;
public event Action<string> MessageReceived;
public event Action Connected;
public event Action Disconnected;
public async Task ConnectAsync(string host, int port)
{
try
{
_client = new TcpClient();
await _client.ConnectAsync(host, port);
_stream = _client.GetStream();
_isConnected = true;
Connected?.Invoke();
// 启动接收循环
_ = ReceiveLoopAsync();
}
catch (Exception ex)
{
GD.PrintErr($"Connect failed: {ex.Message}");
_isConnected = false;
}
}
private async Task ReceiveLoopAsync()
{
byte[] lengthBuffer = new byte[4];
try
{
while (_isConnected)
{
int read = await _stream.ReadAsync(lengthBuffer, 0, 4);
if (read == 0) break;
int bodyLength = IPAddress.NetworkToHostOrder(BitConverter.ToInt32(lengthBuffer, 0));
if (bodyLength <= 0 || bodyLength > 1024 * 1024)
{
GD.PrintErr($"Invalid body length: {bodyLength}");
break;
}
byte[] body = new byte[bodyLength];
int received = 0;
while (received < bodyLength)
{
int n = await _stream.ReadAsync(body, received, bodyLength - received);
if (n == 0) throw new IOException("Connection closed");
received += n;
}
string message = Encoding.UTF8.GetString(body);
MessageReceived?.Invoke(message);
}
}
catch (Exception ex)
{
GD.PrintErr($"Receive error: {ex.Message}");
}
finally
{
_isConnected = false;
Disconnected?.Invoke();
}
}
public async Task SendAsync(string message)
{
if (!_isConnected || _stream == null) return;
byte[] body = Encoding.UTF8.GetBytes(message);
byte[] header = BitConverter.GetBytes(IPAddress.HostToNetworkOrder(body.Length));
await _stream.WriteAsync(header, 0, 4);
await _stream.WriteAsync(body, 0, body.Length);
}
public void Disconnect()
{
_isConnected = false;
_stream?.Close();
_client?.Close();
}
}
这里的思路是:NetworkService 抛出事件,不关心 UI。接收循环常驻一个 Task,只要有数据就解析并通过 MessageReceived 抛出去。这个事件到底在哪个线程触发?这是后面最大的坑。
4.2 Send 方法应该注意的并发问题
SendAsync 看着简单,但如果你在 Godot 里同时从多个地方发消息,比如 UI 点击触发、定时器触发、还有另一个逻辑模块触发,就可能出现两个 WriteAsync 同时写的情况。TcpClient 的 NetworkStream 不保证并发写的线程安全,轻则缓冲区交叉错乱,重则抛 InvalidOperationException。
解决方式很粗暴也有效:发送队列 + 任务链。
一个很常见的模式是信号量锁:
csharp复制private readonly SemaphoreSlim _writeLock = new SemaphoreSlim(1, 1);
public async Task SendAsync(string message)
{
if (!_isConnected || _stream == null) return;
await _writeLock.WaitAsync();
try
{
byte[] body = Encoding.UTF8.GetBytes(message);
byte[] header = BitConverter.GetBytes(IPAddress.HostToNetworkOrder(body.Length));
await _stream.WriteAsync(header, 0, 4);
await _stream.WriteAsync(body, 0, body.Length);
}
finally
{
_writeLock.Release();
}
}
如果你打算做高频发送,还可以用 Channel(System.Threading.Channels)做生产者消费者队列,但那套对新手偏重,本篇先不展开。对于基础学习和中小规模数据量,信号量锁足够。
4.3 如何判断连接状态:一个容易忽略的细节
TcpClient.Connected 属性很坑,它反映的是“最近一次 I/O 操作时连接是否正常”,不是“现在是否真的连着”。TCP 没心跳的话,你无法通过 Connected 判断远端是否断电、断网。
所以我在 ReceiveLoopAsync 里一旦读到 0 字节就认为连接断开,这是比较可靠的方式。只要你不在循环里,连接断了自己根本不知道。基础版本的连接管理必须依赖接收循环的反馈,不要依赖 Connected 属性。
5. 跨线程回传:把网络数据安全地送进 Godot 主线程
5.1 背景线程直接操作节点会怎样
前面那个 NetworkService 里的 MessageReceived?.Invoke(message),如果同步执行,那么谁触发的?是 ReceiveLoopAsync 里的 await _stream.ReadAsync 返回的后续代码,它跑在线程池线程上,不是 Godot 主线程。
如果你在 Godot 脚本里这么写:
csharp复制private void OnMessageReceived(string message)
{
MyLabel.Text = message; // 危险!可能在非主线程执行
}
轻则控制台刷错误,重则直接崩溃。Godot 的渲染和场景树操作不是线程安全的,C# 线程池里的线程改节点属性,等于你在红绿灯路口闭着眼睛开车。
5.2 解决方案:信号 + CallDeferred 双保险
Godot C# 里有一个官方推荐的做法:使用 CallDeferred 把方法调用调度到主线程。虽然这个 API 的本意是“延迟到安全时机再调用”,但它的一个副产物就是线程安全性。你可以把网络回调包装成:
csharp复制private void OnMessageReceived(string message)
{
// 确保回到主线程
if (!IsInsideTree())
return;
CallDeferred(nameof(ApplyMessageToUi), message);
}
private void ApplyMessageToUi(string message)
{
MyLabel.Text = message;
}
CallDeferred 不一定保证线程一定切换,但在 Godot 官方文档里,它确实常被用于跨线程调用。更稳妥的方案是用 GodotSynchronizationContext 或自己写一个线程安全的收件箱。
我自己测试下来,最稳的方式是:
csharp复制private readonly Queue<string> _messageQueue = new Queue<string>();
private readonly object _lock = new object();
private void EnqueueMessage(string msg)
{
lock (_lock)
{
_messageQueue.Enqueue(msg);
}
}
public override void _Process(double delta)
{
lock (_lock)
{
while (_messageQueue.Count > 0)
{
string msg = _messageQueue.Dequeue();
ApplyMessageToUi(msg);
}
}
}
子线程往队列里塞,主线程每帧消费。这种方式不会丢失消息,也不会跨线程调用节点,而且逻辑直白好调试。唯一的缺点是要自己写一点模板代码。如果你喜欢更简练的写法,可以用 GodotSynchronizationContext,但它对框架内部状态敏感,不如 Queue 容易理解。
5.3 事件订阅的泄漏问题
C# 的 event 如果用 += 订阅,一定要在节点释放时 -= 。Godot 场景卸载时节点并没有真正消失,只是不再参与渲染,事件系统如果有引用,会导致订阅者无法被 GC,也就是经典的内存泄漏。
我的做法是在 _ExitTree 里统一取消订阅:
csharp复制public override void _ExitTree()
{
base._ExitTree();
if (_networkService != null)
{
_networkService.MessageReceived -= OnMessageReceived;
_networkService.Disconnected -= OnDisconnected;
}
}
这段代码你一定要写上,否则在编辑器里反复运行场景,内存占用会肉眼可见地涨。C# 的事件注册和注销这档子事,在 Godot 场景树这个生命周期自主的环境里特别容易忽视。
6. 断线自愈:心跳检测与自动重连怎么写才稳
6.1 先定义“断线”的两种含义
断线分两种。一种是程序能感知到的:读返回 0、异常抛出来,这种好办。另一种是程序完全感知不到的:比如中间路由器把连接断了,但两端都没有任何数据交互,TCP 不会主动通知你。后者才是真实环境里最让人头疼的。
要解决它,只能靠心跳——应用层的心跳包。每隔几秒发一个很小字符串,服务端收到后回一个或直接忽略,只要能活着就是连接正常。
在 NetworkService 里,起一个心跳定时器:
csharp复制private async Task HeartbeatLoopAsync()
{
while (_isConnected)
{
await Task.Delay(3000);
if (_isConnected)
{
await SendAsync("__heartbeat__");
}
}
}
服务端那边可以对消息内容做判断,收到 __heartbeat__ 就跳过业务处理。这套逻辑在控制台服务端里面也能扩展,但是要注意:心跳和业务消息共用一个接收队列,解析时必须按消息内容判断类型,不能靠端口区分。
6.2 自动重连的指数退避算法
重连这件事,不能断开后立刻疯狂重试。如果服务端还在崩溃重启中,客户端会进入一个死循环,疯狂发连接请求,把服务器端口打爆。正确做法是指数退避:
csharp复制private int _retryCount = 0;
private async Task TryConnectWithRetryAsync(string host, int port)
{
while (!_isConnected)
{
int delay = Math.Min(30, (int)Math.Pow(2, _retryCount));
GD.Print($"Reconnecting in {delay}s...");
await Task.Delay(delay * 1000);
try
{
await ConnectAsync(host, port);
if (_isConnected)
{
_retryCount = 0;
GD.Print("Reconnected!");
}
else
{
_retryCount++;
}
}
catch
{
_retryCount++;
}
}
}
注意重连次数多了之后,指数爆炸会非常快,所以要封顶。我这里封顶 30 秒,够用。你也可以加一点随机抖动(jitter),防止多个客户端同时重连导致服务器拥堵。
6.3 连接状态在 UI 层的表现
状态机是必须的:断开、连接中、已连接、重连中。这四个状态在 UI 上表现完全不同。我在 UI 层用一个枚举加一个 Label 显示:
- 断开:灰字
- 连接中:黄字
- 已连接:绿字
- 重连中:橙色字
代码层面,每次状态变化就触发一个信号或事件,UI 层订阅之后更新 Label。不要在子线程直接更新 Label,哪怕改一下字符串也不行,必须走队列或 CallDeferred。这是老生常谈,但真的每个 C# 新手都会踩。
7. 实测记录:从编辑器到打包 exe 的踩坑点
7.1 编辑器下跑通很简单,但发布配置有玄机
Godot .NET 版的项目发布时,需要特别注意导出模板。你在导出窗口中会看到 .NET 相关的选项:一个是框架依赖发布(Framework-dependent),一个是自包含发布(Self-contained)。如果目标机器没装 .NET 运行时,你就得选 Self-contained,但这样 exe 体积会大很多。
还有一个坑:导出的时候如果没勾选“Copy Native Libraries”,打出来的包很可能双击没反应。 这个选项具体名字在不同版本略有差异,但本质是把 Godot 的 native 库带出去。
我实际遇到的情况是,在编辑器里测试网络一切正常,但打包成 exe 后怎么都连不上本地服务端。查了半天,发现是 Windows 防火墙默认阻止了未签名的 exe 访问网络端口。解决方式很朴素:第一次运行时允许访问专用网络,或者管理员身份运行一次并放行。
7.2 常见异常和信息排查表
我把这段时间踩过的典型问题整理成表,方便以后排查:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 双击 exe 无反应 | 缺少 .NET 运行时 / native 库未复制 | 检查发布模式,换 Self-contained |
| 编辑器正常,exe 连不上 | 防火墙拦截 / 模块初始化顺序 | 防火墙放行、查看日志 |
AccessViolationException |
非主线程访问场景树节点 | 检查是否用 CallDeferred 或队列 |
| 连接一两秒后断开 | 服务端没配心跳/超时;客户端接收循环异常 | 查看服务端异常日志 |
| 消息乱码 | 编码不一致 | 统一 UTF-8,排查长度前缀解析 |
| 程序退出时卡死 | TcpClient 未关闭,线程未结束 | 在 _ExitTree 里 Disconnect 并加超时 |
AccessViolationException 这个词在 C# 开发里常出现,尤其是做串口、设备通信、网络库时。它通常不是真的内存越界,而是线程没有正确同步导致的。Godot 场景树拒绝非主线程访问时,有时会抛出这种异常,看起来像是原生崩溃,非常吓人。解决办法就是回到第 5 节的主线程桥接方案。
7.3 连接不上本地调试服务时的快速测试方法
如果你怀疑客户端逻辑有问题,但又不确定,可以先用一个独立的 C# 控制台客户端连接同一个服务端,跑同样的协议。如果控制台客户端能通,说明问题出在 Godot 集成层;如果控制台也连不上,说明服务端或网络环境有问题。这个二分法能帮你快速缩小范围。
我经常用的命令是 telnet 127.0.0.1 6000 先看端口通不通,不通就查服务端有没有启动、有没有被防火墙拦截。通了再用工具发数据。记住:先排除网络环境问题,再怀疑 Godot 的集成问题,否则你会浪费大量时间。
7.4 自定义类的线程归属与静态成员
我在开发中还发现一个有意思的坑:如果你把 NetworkService 写成一个普通 C# 类,它不属于场景树,Godot 的线程模型不会自动保护它。所以跨线程方案完全要靠你自己实现。反过来,如果你把网络收发的生命周期绑定到一个 Node 节点上,利用 _Process 做消息分发,就会自然很多。
此外,C# 静态成员在 Godot 热重载(Play 停止后重新运行)时可能会保留旧状态。如果你的静态变量里有 TcpClient 的实例,第二次进入游戏时会发现连不上,因为旧连接没释放。这种状态残留比 GDScript 里的全局变量更隐蔽。我的习惯是:网络相关状态尽量做成实例成员,避免使用静态字段。
8. 一些个人体会:C# 网络与 Godot 的协作边界
写了这么多,最后分享一点感想。
Godot 的 C# 集成质量在一路提升,但它毕竟不是 .NET 的主场。你用 C# 写游戏逻辑时,会感觉自己在“借用”一个游戏引擎;而你用 C# 写网络层时,又会发现自己回到了熟悉的 .NET 世界。这两套思维模式在同一个进程里共存,靠的是明确的分界线:网络层大胆使用 C# 原生能力,表现层老老实实回到 Godot 主线程。
在我自己的实际项目中,我最满意的组合是:Godot 负责 3D 场景渲染和 UI 交互,C# 网络层负责与本地服务通信,数据到达后通过队列通知 UI 更新状态。这套架构跑起来后,稳定性比最初的裸写法好上一个档次,调试也容易很多——看到网络日志是网络层的问题,看到节点报错是表现层的问题,互不甩锅。
如果你也在做类似的 Godot C# 项目,不需要一开始就追求高深的网络框架,先把 TcpClient 和服务端搭起来,把粘包、跨线程、心跳这三个基本功练扎实,后面接 ENet、接 WebSocket、接 SignalR 都是顺手的事。重要的是你先跑通一条最基础的链路,然后基于这条链路逐渐加需求。这样即使遇到问题,你也有清晰的排查路径。
第 10 篇笔记就到这里。下次我打算深入写一写 Godot 里 C# 与 GDScript 混用时的通信方式,或者再聊聊 WebSocket 的接入,看大家更需要哪个。
