4. 网络基础
网络基础
你有没有想过,为什么你在家里玩游戏,队友在上海、纽约、东京,你们却能在同一个游戏世界里一起打怪?这就需要网络通信。
网络让多台电脑(或手机)之间可以互相"说话"。在网络游戏中,每台设备都在不停地收发消息——"我移动到了这里"、"我开了一枪"、"这个怪物掉了血"。本章教你网络游戏的底层原理和 Godot 中的实现方式。
网络游戏的基本概念
客户端与服务器
打个比方:你去餐厅吃饭。
- 服务器(Server)就像厨房——所有订单汇总到这里,厨师(游戏逻辑)统一处理,然后把做好的菜(游戏状态)端出来
- 客户端(Client)就像顾客——你只能看菜单点菜、看菜端上来,不能直接进厨房炒菜
在网络游戏中:
- 服务器负责运行游戏逻辑、计算碰撞、管理所有玩家
- 客户端负责显示画面、播放声音、接收玩家输入
为什么需要服务器?
如果没有服务器,每个玩家自己算自己的,就会出现"你打中了但他说没打中"的矛盾。服务器就是公正的裁判——所有人的操作都交给服务器判断。
延迟(Ping)
延迟就是数据从你的电脑到服务器再回来所花的时间。单位是毫秒(ms)。
| 延迟范围 | 体验 |
|---|---|
| 0-50ms | 几乎感觉不到,非常流畅 |
| 50-100ms | 略有延迟,但可以接受 |
| 100-200ms | 明显感觉到延迟,竞技游戏受影响 |
| 200ms+ | 严重影响体验,操作跟不上画面 |
打个比方:延迟就像你和朋友用对讲机聊天。你说了一句话,对方过一会儿才能听到,再过一会儿你才能听到他的回答。延迟越高,这个"过一会儿"就越长,对话就越不自然。
帧率(FPS)与网络帧率(Tick Rate)
- 帧率(FPS):你的电脑每秒渲染多少张画面。60 FPS 就是每秒显示 60 张图片
- 网络帧率(Tick Rate):服务器每秒处理多少次游戏逻辑。128 Tick 就是服务器每秒更新 128 次游戏状态
常见误区
帧率高不代表网络流畅。你的电脑跑 144 FPS,但如果服务器只有 20 Tick,你看到的画面虽然丝滑,但游戏状态的更新频率很低——你看到的敌人位置可能是 50 毫秒前的。
Godot 的网络架构
Godot 4 提供了两种主要的网络方案:
| 方案 | 适用场景 | 特点 |
|---|---|---|
| ENet | 实时多人游戏 | 基于 UDP,低延迟,支持房间和频道 |
| WebSocket | 网页游戏、聊天应用 | 基于 TCP,可靠传输,兼容浏览器 |
简单来说:
- ENet 适合需要快速响应的实时游戏(比如射击、动作、格斗)
- WebSocket 适合对延迟不敏感但需要可靠传输的应用(比如卡牌游戏、聊天室)
ENet 基础
ENet 是 Godot 内置的网络库。使用之前,需要在项目设置中启用:
- 打开 Project -> Project Settings -> Networking
- 确保
Server和Client的默认端口已设置(默认 7777)
状态同步 vs 帧同步
这是网络游戏中最核心的两个概念,理解它们对于设计网络游戏至关重要。
状态同步(State Synchronization)
打个比方:状态同步就像给朋友直播做饭。
你的朋友(客户端)在家里看着你的直播画面(服务器发来的游戏状态)。你的朋友不需要自己做饭,只需要看着你做就行了。如果直播卡了一下(网络延迟),你的朋友就看到画面跳了一帧,但他不需要重新做饭——继续看直播就好。
具体来说:
- 服务器运行完整的游戏逻辑
- 每隔一段时间,服务器把所有游戏对象的位置、状态等信息打包发给客户端
- 客户端收到后,把这些信息"搬运"到自己的画面上显示
优点:
- 客户端逻辑简单,只负责显示
- 不容易作弊(游戏逻辑在服务器上运行)
- 不同设备之间的计算差异不影响结果
缺点:
- 服务器压力大(所有计算都在服务器上)
- 网络延迟高时体验差(画面会"跳跃")
帧同步(Lockstep)
打个比方:帧同步就像多人一起跳广场舞。
所有人(所有客户端)听着同一首音乐(同一个游戏输入序列),按照同样的步骤跳舞(运行同样的游戏逻辑)。只要每个人都在同一节拍上做动作,跳出来的效果就完全一样。
具体来说:
- 每个客户端都运行完整的游戏逻辑
- 服务器只负责收集和转发玩家的操作指令("按了左键"、"按了跳跃")
- 所有客户端收到相同的操作序列,执行相同的结果
优点:
- 服务器压力小(只转发操作指令,数据量小)
- 适合大量玩家同时在线
缺点:
- 所有客户端必须严格同步,一个人卡了所有人都得等
- 容易作弊(游戏逻辑在客户端上运行)
- 浮点数计算在不同设备上可能有微小差异,导致"蝴蝶效应"——一局游戏下来差异越来越大
怎么选?
- 大多数游戏用状态同步:射击、MOBA、MMO 等
- 帧同步适合特定场景:RTS(即时战略)、回合制游戏、格斗游戏
- 作为新手,优先学习状态同步,它更通用、更容易调试
简单的多人聊天室示例
下面用 ENet 实现一个最基础的多人聊天室。这个例子虽然简单,但包含了网络游戏的核心要素:建立连接、收发消息、管理多个客户端。
服务器端代码
using Godot;
using System;
using System.Collections.Generic;
public partial class ChatServer : Node
{
private ENetMultiplayerPeer _peer;
private List<int> _connectedPlayers = new();
[Export] public int Port { get; set; } = 7777;
[Export] public int MaxClients { get; set; } = 32;
public override void _Ready()
{
// 创建服务器
_peer = new ENetMultiplayerPeer();
Error result = _peer.CreateServer(Port, MaxClients);
if (result != Error.Ok)
{
GD.PrintErr($"服务器启动失败:{result}");
return;
}
// 将网络连接注册到 MultiplayerAPI
Multiplayer.MultiplayerPeer = _peer;
// 连接信号
Multiplayer.PeerConnected += OnPeerConnected;
Multiplayer.PeerDisconnected += OnPeerDisconnected;
GD.Print($"服务器已启动,端口:{Port},最大客户端数:{MaxClients}");
}
// 有新玩家连接
private void OnPeerConnected(long peerId)
{
GD.Print($"玩家 {peerId} 已连接");
_connectedPlayers.Add((int)peerId);
// 通知所有人有新玩家加入
Rpc("OnPlayerJoined", peerId, _connectedPlayers.Count);
}
// 有玩家断开连接
private void OnPeerDisconnected(long peerId)
{
GD.Print($"玩家 {peerId} 已断开");
_connectedPlayers.Remove((int)peerId);
// 通知所有人有玩家离开
Rpc("OnPlayerLeft", peerId);
}
// 客户端发来聊天消息,广播给所有人
[Rpc(MultiplayerApi.RpcMode.AnyPeer, CallLocal = false)]
private void ReceiveChatMessage(string playerName, string message, long senderId)
{
GD.Print($"[{playerName}] {message}");
// 转发给所有客户端
Rpc("DisplayChatMessage", playerName, message);
}
// 以下 RPC 方法在客户端执行
[Rpc(MultiplayerApi.RpcMode.Authority, CallLocal = false)]
private void OnPlayerJoined(long peerId, int totalPlayers)
{
GD.Print($"欢迎玩家 {peerId}!当前在线人数:{totalPlayers}");
}
[Rpc(MultiplayerApi.RpcMode.Authority, CallLocal = false)]
private void DisplayChatMessage(string playerName, string message)
{
GD.Print($"[聊天] {playerName}: {message}");
}
[Rpc(MultiplayerApi.RpcMode.Authority, CallLocal = false)]
private void OnPlayerLeft(long peerId)
{
GD.Print($"玩家 {peerId} 已离开");
}
}extends Node
var _peer: ENetMultiplayerPeer
var _connected_players: Array[int] = []
@export var port: int = 7777
@export var max_clients: int = 32
func _ready() -> void:
# 创建服务器
_peer = ENetMultiplayerPeer.new()
var result := _peer.create_server(port, max_clients)
if result != OK:
push_error("服务器启动失败:%s" % result)
return
# 将网络连接注册到 MultiplayerAPI
multiplayer.multiplayer_peer = _peer
# 连接信号
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(_on_peer_disconnected)
print("服务器已启动,端口:%d,最大客户端数:%d" % [port, max_clients])
# 有新玩家连接
func _on_peer_connected(peer_id: int) -> void:
print("玩家 %d 已连接" % peer_id)
_connected_players.append(peer_id)
# 通知所有人有新玩家加入
_on_player_joined.rpc(peer_id, _connected_players.size())
# 有玩家断开连接
func _on_peer_disconnected(peer_id: int) -> void:
print("玩家 %d 已断开" % peer_id)
_connected_players.erase(peer_id)
# 通知所有人有玩家离开
_on_player_left.rpc(peer_id)
# 客户端发来聊天消息,广播给所有人
@rpc(any_peer, call_local = false)
func receive_chat_message(player_name: String, message: String, sender_id: int) -> void:
print("[%s] %s" % [player_name, message])
# 转发给所有客户端
display_chat_message.rpc(player_name, message)
# 以下 RPC 方法在客户端执行
@rpc(authority, call_local = false)
func _on_player_joined(peer_id: int, total_players: int) -> void:
print("欢迎玩家 %d!当前在线人数:%d" % [peer_id, total_players])
@rpc(authority, call_local = false)
func display_chat_message(player_name: String, message: String) -> void:
print("[聊天] %s: %s" % [player_name, message])
@rpc(authority, call_local = false)
func _on_player_left(peer_id: int) -> void:
print("玩家 %d 已离开" % peer_id)客户端代码
using Godot;
public partial class ChatClient : Node
{
private ENetMultiplayerPeer _peer;
[Export] public string ServerAddress { get; set; } = "127.0.0.1";
[Export] public int ServerPort { get; set; } = 7777;
[Export] public string PlayerName { get; set; } = "Player";
// UI 引用
private LineEdit _inputField;
private RichTextLabel _chatDisplay;
public override void _Ready()
{
_inputField = GetNode<LineEdit>("VBox/LineEdit");
_chatDisplay = GetNode<RichTextLabel>("VBox/ChatDisplay");
_inputField.TextSubmitted += OnMessageSubmitted;
ConnectToServer();
}
public void ConnectToServer()
{
_peer = new ENetMultiplayerPeer();
Error result = _peer.CreateClient(ServerAddress, ServerPort);
if (result != Error.Ok)
{
GD.PrintErr($"连接服务器失败:{result}");
return;
}
Multiplayer.MultiplayerPeer = _peer;
Multiplayer.ConnectedToServer += OnConnectedToServer;
Multiplayer.ConnectionFailed += OnConnectionFailed;
Multiplayer.ServerDisconnected += OnServerDisconnected;
GD.Print($"正在连接服务器 {ServerAddress}:{ServerPort}...");
}
private void OnConnectedToServer()
{
GD.Print("已连接到服务器!");
AppendChatMessage("系统", "已连接到服务器", Colors.Green);
}
private void OnConnectionFailed()
{
GD.PrintErr("连接服务器失败");
AppendChatMessage("系统", "连接失败,请检查网络", Colors.Red);
}
private void OnServerDisconnected()
{
GD.Print("与服务器断开连接");
AppendChatMessage("系统", "与服务器断开连接", Colors.Red);
}
private void OnMessageSubmitted(string message)
{
if (string.IsNullOrWhiteSpace(message)) return;
// 发送消息到服务器(RPC)
RpcId(1, "ReceiveChatMessage", PlayerName, message, Multiplayer.GetUniqueId());
_inputField.Text = "";
}
// 服务器转发来的聊天消息
[Rpc(MultiplayerApi.RpcMode.Authority)]
private void DisplayChatMessage(string playerName, string message)
{
AppendChatMessage(playerName, message, Colors.White);
}
private void AppendChatMessage(string sender, string message, Color color)
{
_chatDisplay.PushColor(color);
_chatDisplay.AppendText($"[{sender}] {message}\n");
}
}extends Node
var _peer: ENetMultiplayerPeer
@export var server_address: String = "127.0.0.1"
@export var server_port: int = 7777
@export var player_name: String = "Player"
@onready var _input_field: LineEdit = $VBox/LineEdit
@onready var _chat_display: RichTextLabel = $VBox/ChatDisplay
func _ready() -> void:
_input_field.text_submitted.connect(_on_message_submitted)
connect_to_server()
func connect_to_server() -> void:
_peer = ENetMultiplayerPeer.new()
var result := _peer.create_client(server_address, server_port)
if result != OK:
push_error("连接服务器失败:%s" % result)
return
multiplayer.multiplayer_peer = _peer
multiplayer.connected_to_server.connect(_on_connected_to_server)
multiplayer.connection_failed.connect(_on_connection_failed)
multiplayer.server_disconnected.connect(_on_server_disconnected)
print("正在连接服务器 %s:%d..." % [server_address, server_port])
func _on_connected_to_server() -> void:
print("已连接到服务器!")
_append_chat_message("系统", "已连接到服务器", Color.GREEN)
func _on_connection_failed() -> void:
push_error("连接服务器失败")
_append_chat_message("系统", "连接失败,请检查网络", Color.RED)
func _on_server_disconnected() -> void:
print("与服务器断开连接")
_append_chat_message("系统", "与服务器断开连接", Color.RED)
func _on_message_submitted(message: String) -> void:
if message.strip_edges() == "":
return
# 发送消息到服务器(RPC)
receive_chat_message.rpc_id(1, player_name, message, multiplayer.get_unique_id())
_input_field.text = ""
# 服务器转发来的聊天消息
@rpc(authority)
func display_chat_message(player_name: String, message: String) -> void:
_append_chat_message(player_name, message, Color.WHITE)
func _append_chat_message(sender: String, message: String, color: Color) -> void:
_chat_display.push_color(color)
_chat_display.append_text("[%s] %s\n" % [sender, message])RPC 详解——远程过程调用
RPC(Remote Procedure Call,远程过程调用)是 Godot 网络系统的核心机制。简单来说:
- 标记了
[Rpc]的方法,可以被远程设备调用 - 你在客户端 A 调用了一个 RPC 方法,服务器和其他客户端都会执行这个方法
RPC 属性说明
| 属性 | 含义 |
|---|---|
@rpc(any_peer) | 任何客户端都可以调用这个方法 |
@rpc(authority) | 只有服务器可以调用这个方法 |
@rpc(call_local) | 调用者在本地也执行 |
@rpc(reliable) | 消息一定会到达,但可能延迟(TCP 模式) |
@rpc(unreliable) | 消息可能丢失,但速度快(UDP 模式) |
什么时候用 reliable,什么时候用 unreliable?
- reliable(可靠):聊天消息、重要的状态变化、游戏结束通知
- unreliable(不可靠):位置更新、动画状态、临时特效——丢了就丢了,下一帧会发新的
网络安全基础
网络游戏的作弊问题比单机游戏严重得多,因为作弊者会影响其他玩家的体验。
永远不要信任客户端
这是网络游戏安全的第一原则。
| 应该在服务器做的 | 不应该在客户端做的 |
|---|---|
| 判断玩家是否打中了敌人 | 客户端自己判断命中 |
| 计算伤害数值 | 客户端自己算伤害 |
| 验证物品拾取 | 客户端说"我捡了龙刀" |
| 验证玩家移动(防穿墙) | 客户端自己决定位置 |
打个比方:客户端就像一个考生,服务器就像监考老师。你不能让考生自己判卷子——他肯定会给自己打满分。
常见作弊手段及防范
| 作弊手段 | 描述 | 防范方法 |
|---|---|---|
| 内存修改 | 用工具修改金币、血量等数值 | 服务器验证,关键数据存在服务器 |
| 封包修改 | 拦截和修改网络数据包 | 加密通信,服务器二次验证 |
| 加速器 | 让客户端运行速度比正常快 | 服务器按固定步长更新 |
| 透视外挂 | 看到不该看到的信息 | 服务器只发送必要信息 |
| 自动瞄准 | 自动锁定敌人 | 服务器检测异常精准度 |
重要提醒
对于大多数初学者来说,先做出功能完整的游戏,再考虑安全问题。过早优化安全会导致开发效率大幅下降。但如果你做的是竞技类游戏(比如 FPS、MOBA),安全就要从第一天开始考虑。
本章小结
| 概念 | 一句话解释 |
|---|---|
| 客户端/服务器 | 客户端显示画面,服务器计算逻辑 |
| 延迟(Ping) | 数据一来一回的时间 |
| 状态同步 | 服务器算好状态,告诉客户端"显示这个" |
| 帧同步 | 服务器转发操作,客户端自己算结果 |
| ENet | Godot 内置的网络库,适合实时游戏 |
| RPC | 远程过程调用,让代码在多台设备上执行 |
| 网络安全 | 永远不要信任客户端 |
下一步建议
- 先用上面的聊天室代码跑通一个最简单的多人程序
- 尝试在聊天室基础上添加"玩家位置同步"功能
- 然后逐步增加功能:移动、碰撞、血量、攻击
- 等基础功能完善后,再考虑安全优化
