6. 网络游戏之多人战斗
网络游戏之多人战斗
上一章我们做了卡牌游戏——回合制、不需要实时同步。这一章要做的是多人实时战斗,比如几个人在同一个地图上打来打去。
实时战斗比卡牌游戏难得多,因为你需要让所有玩家在同一时刻看到"差不多"的画面。这个"差不多"就是网络同步的核心挑战。
回合制 vs 实时——难度的跨越
| 对比项 | 回合制(卡牌) | 实时战斗 |
|---|---|---|
| 网络延迟要求 | 低(1 秒内响应就行) | 高(100ms 以内才有好的体验) |
| 数据量 | 小(每次操作几条消息) | 大(每帧都要同步位置、状态) |
| 同步方式 | 服务器判定,客户端等待 | 客户端预测,服务器校正 |
| 实现难度 | ★★☆☆☆ | ★★★★★ |
用生活比喻理解
- 回合制就像下棋:你走一步,等对方走一步,网络慢一点也没关系
- 实时战斗就像打篮球:所有人的动作是同时发生的,你传球的时机、跑位的角度都需要精确配合。如果网络延迟,对方"看到"的你和"实际"的你就不在同一位置了
网络同步的核心问题
问题一:延迟
信号从你的手机传到服务器,再传到对手的手机,需要时间。这个时间通常在 50ms-200ms 之间。
200ms 意味着什么? 60FPS 的游戏,一帧大约 16ms。200ms 等于 12 帧的延迟。如果玩家 A 按下攻击键,玩家 B 要等 12 帧才能看到——这期间玩家 A 可能已经移动了很远。
问题二:不同步
每个玩家的设备性能不同,帧率不同。玩家 A 的手机跑 60FPS,玩家 B 的电脑跑 144FPS。同一秒钟内,A 计算了 60 次,B 计算了 144 次。
问题三:作弊
在实时游戏中,如果位置数据由客户端上报,作弊者可以"瞬间移动"到对手身边。所有关键数据都必须由服务器验证。
三种主流同步方案
| 方案 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 状态同步 | 服务器计算所有逻辑,把状态发给客户端 | 最安全,防作弊 | 服务器压力大 | MOBA、RTS |
| 帧同步(Lockstep) | 所有客户端执行相同的输入序列 | 带宽低,一致性好 | 对延迟敏感 | 格斗、RTS |
| 客户端预测 + 服务器校正 | 客户端先执行,服务器再确认 | 体验流畅 | 实现复杂 | FPS、动作游戏 |
新手推荐
对于刚接触网络编程的开发者,建议从状态同步开始。它最容易理解和实现,虽然服务器开销大一些,但对于中小规模的游戏完全够用。
状态同步实现
原理
客户端只发送输入操作("我按了向左"),服务器接收所有操作,计算所有角色的位置和状态,然后把结果广播给所有客户端。客户端根据服务器发来的数据渲染画面。
玩家 A: "我按了攻击键" ──→ 服务器: A 攻击了 B,B 掉了 30 血 ──→ 玩家 B 看到 A 攻击了自己
玩家 B: "我向右移动" ──→ 服务器: B 移动到了 (150, 200) ──→ 玩家 A 看到 B 移动了Godot 的多人游戏 API
Godot 提供了内置的多人游戏支持。以下是一个基于状态同步的简单战斗示例:
// 多人游戏管理器
using Godot;
public partial class MultiplayerManager : Node
{
private const string DefaultAddress = "127.0.0.1";
private const int DefaultPort = 8910;
// 创建主机(既是服务器又是客户端)
public void CreateHost()
{
var peer = new ENetMultiplayerPeer();
Error err = peer.CreateServer(DefaultPort);
if (err != Error.Ok)
{
GD.PrintErr($"创建服务器失败:{err}");
return;
}
Multiplayer.MultiplayerPeer = peer;
GD.Print("服务器已创建");
}
// 加入游戏(作为客户端)
public void JoinGame(string address = DefaultAddress)
{
var peer = new ENetMultiplayerPeer();
Error err = peer.CreateClient(address, DefaultPort);
if (err != Error.Ok)
{
GD.PrintErr($"连接服务器失败:{err}");
return;
}
Multiplayer.MultiplayerPeer = peer;
GD.Print($"正在连接 {address}:{DefaultPort}...");
}
// 当有玩家连接时
[Rpc(MultiplayerApi.RpcMode.AnyPeer, CallLocal = true)]
public void OnPlayerConnected(int playerId)
{
GD.Print($"玩家 {playerId} 加入了游戏");
}
// 当有玩家断开时
[Rpc(MultiplayerApi.RpcMode.AnyPeer, CallLocal = true)]
public void OnPlayerDisconnected(int playerId)
{
GD.Print($"玩家 {playerId} 离开了游戏");
}
}# 多人游戏管理器
extends Node
const DEFAULT_ADDRESS = "127.0.0.1"
const DEFAULT_PORT = 8910
# 创建主机(既是服务器又是客户端)
func create_host() -> void:
var peer = ENetMultiplayerPeer.new()
var err = peer.create_server(DEFAULT_PORT)
if err != OK:
push_error("创建服务器失败:%s" % err)
return
multiplayer.multiplayer_peer = peer
print("服务器已创建")
# 加入游戏(作为客户端)
func join_game(address: String = DEFAULT_ADDRESS) -> void:
var peer = ENetMultiplayerPeer.new()
var err = peer.create_client(address, DEFAULT_PORT)
if err != OK:
push_error("连接服务器失败:%s" % err)
return
multiplayer.multiplayer_peer = peer
print("正在连接 %s:%d..." % [address, DEFAULT_PORT])
# 当有玩家连接时
@rpc(any_peer, call_local)
func on_player_connected(player_id: int):
print("玩家 %d 加入了游戏" % player_id)
# 当有玩家断开时
@rpc(any_peer, call_local)
func on_player_disconnected(player_id: int):
print("玩家 %d 离开了游戏" % player_id)网络角色控制
在状态同步中,玩家只发送自己的输入,服务器广播所有玩家的位置:
// 网络角色——只有自己控制的角色才发送位置
using Godot;
public partial class NetworkPlayer : CharacterBody2D
{
[Export] public float Speed = 200.0f;
// 每帧将位置同步给其他玩家
public override void _PhysicsProcess(double delta)
{
if (Multiplayer.IsServer())
{
// 服务器上:所有角色都执行物理逻辑
// (服务器也会收到客户端的输入并应用)
}
else
{
// 客户端上:只有自己控制的角色才处理输入
if (Name.ToString() == Multiplayer.GetUniqueId().ToString())
{
HandleInput(delta);
}
}
}
private void HandleInput(double delta)
{
var velocity = Vector2.Zero;
if (Input.IsActionPressed("move_up"))
velocity.Y -= 1;
if (Input.IsActionPressed("move_down"))
velocity.Y += 1;
if (Input.IsActionPressed("move_left"))
velocity.X -= 1;
if (Input.IsActionPressed("move_right"))
velocity.X += 1;
velocity = velocity.Normalized() * Speed;
Velocity = velocity;
MoveAndSlide();
}
}# 网络角色——只有自己控制的角色才发送位置
extends CharacterBody2D
@export var speed: float = 200.0
# 每帧将位置同步给其他玩家
func _physics_process(delta):
if multiplayer.is_server():
# 服务器上:所有角色都执行物理逻辑
# (服务器也会收到客户端的输入并应用)
pass
else:
# 客户端上:只有自己控制的角色才处理输入
if str(name) == str(multiplayer.get_unique_id()):
handle_input(delta)
func handle_input(delta):
var velocity = Vector2.ZERO
if Input.is_action_pressed("move_up"):
velocity.y -= 1
if Input.is_action_pressed("move_down"):
velocity.y += 1
if Input.is_action_pressed("move_left"):
velocity.x -= 1
if Input.is_action_pressed("move_right"):
velocity.x += 1
velocity = velocity.normalized() * speed
velocity = velocity
move_and_slide()使用 @rpc 同步操作
Godot 的 @rpc 注解可以将函数调用同步到所有客户端:
// 使用 RPC 同步攻击动作
public partial class CombatPlayer : NetworkPlayer
{
[Export] public int AttackDamage = 20;
[Export] public float AttackRange = 50.0f;
// 客户端发起攻击请求
public void RequestAttack()
{
RpcId(1, nameof(ServerHandleAttack), Multiplayer.GetUniqueId());
}
// 服务器处理攻击逻辑
[Rpc(MultiplayerApi.RpcMode.AnyPeer)]
private void ServerHandleAttack(int attackerId)
{
if (!Multiplayer.IsServer()) return;
var attacker = GetParent().GetNode<CharacterBody2D>(attackerId.ToString());
if (attacker == null) return;
// 检查攻击范围内的所有角色
var spaceState = GetWorld2D().DirectSpaceState;
var query = new PhysicsPointQueryParameters2D
{
Position = attacker.Position,
Radius = AttackRange,
Exclude = new Godot.Collections.Array<Rid> { attacker.GetRid() }
};
var results = spaceState.IntersectPoint(query);
foreach (var result in results)
{
var target = (CharacterBody2D)result["collider"];
if (target.IsInGroup("players"))
{
int targetId = target.GetMultiplayerAuthority();
// 通知所有客户端播放攻击效果
Rpc(nameof(ClientShowAttackEffect), attackerId, targetId, AttackDamage);
// 服务器扣血
target.Call("TakeDamage", AttackDamage);
}
}
}
// 客户端播放攻击效果
[Rpc(MultiplayerApi.RpcMode.Authority)]
private void ClientShowAttackEffect(int attackerId, int targetId, int damage)
{
GD.Print($"玩家 {attackerId} 攻击了玩家 {targetId},造成 {damage} 伤害");
// 播放攻击动画、伤害数字等视觉效果
}
}# 使用 RPC 同步攻击动作
extends CharacterBody2D
@export var attack_damage: int = 20
@export var attack_range: float = 50.0
# 客户端发起攻击请求
func request_attack():
rpc_id(1, &"server_handle_attack", multiplayer.get_unique_id())
# 服务器处理攻击逻辑
@rpc(any_peer)
func server_handle_attack(attacker_id: int):
if not multiplayer.is_server():
return
var attacker = get_parent().get_node_or_null(str(attacker_id))
if attacker == null:
return
# 检查攻击范围内的所有角色
var space_state = get_world_2d().direct_space_state
var query = PhysicsPointQueryParameters2D.new()
query.position = attacker.position
query.radius = attack_range
query.exclude = [attacker.get_rid()]
var results = space_state.intersect_point(query)
for result in results:
var target = result["collider"]
if target.is_in_group("players"):
var target_id = target.get_multiplayer_authority()
# 通知所有客户端播放攻击效果
rpc(&"client_show_attack_effect", attacker_id, target_id, attack_damage)
# 服务器扣血
target.take_damage(attack_damage)
# 客户端播放攻击效果
@rpc(authority)
func client_show_attack_effect(attacker_id: int, target_id: int, damage: int):
print("玩家 %d 攻击了玩家 %d,造成 %d 伤害" % [attacker_id, target_id, damage])
# 播放攻击动画、伤害数字等视觉效果客户端预测——让操作更流畅
状态同步的问题是:玩家按下移动键后,要等服务器返回新位置才能看到自己移动。这会让操作感觉很"黏"。
客户端预测就是让客户端先"猜"自己会移到哪里,立即在画面上显示。等服务器返回真实位置后,再进行校正。
// 带客户端预测的网络角色
using Godot;
public partial class PredictedPlayer : CharacterBody2D
{
[Export] public float Speed = 200.0f;
private Vector2 _lastServerPosition;
private bool _hasServerUpdate;
public override void _PhysicsProcess(double delta)
{
// 处理输入(本地预测)
var velocity = Vector2.Zero;
if (Input.IsActionPressed("move_up")) velocity.Y -= 1;
if (Input.IsActionPressed("move_down")) velocity.Y += 1;
if (Input.IsActionPressed("move_left")) velocity.X -= 1;
if (Input.IsActionPressed("move_right")) velocity.X += 1;
velocity = velocity.Normalized() * Speed;
Velocity = velocity;
MoveAndSlide();
// 发送输入给服务器
RpcId(1, nameof(ServerReceiveInput), velocity);
}
// 服务器广播位置更新
[Rpc(MultiplayerApi.RpcMode.Authority, CallLocal = false)]
public void UpdatePosition(Vector2 serverPosition)
{
// 如果服务器位置和预测位置差距太大,进行校正
float distance = Position.DistanceTo(serverPosition);
if (distance > 5.0f)
{
// 平滑过渡到服务器位置,而不是直接跳过去
_lastServerPosition = serverPosition;
_hasServerUpdate = true;
}
}
public override void _Process(double delta)
{
if (_hasServerUpdate)
{
// 平滑插值到服务器位置
Position = Position.Lerp(_lastServerPosition, 0.3f);
if (Position.DistanceTo(_lastServerPosition) < 0.5f)
{
Position = _lastServerPosition;
_hasServerUpdate = false;
}
}
}
// 服务器接收输入
[Rpc(MultiplayerApi.RpcMode.AnyPeer)]
private void ServerReceiveInput(Vector2 velocity, int senderId)
{
if (!Multiplayer.IsServer()) return;
var player = GetParent().GetNode<PredictedPlayer>(senderId.ToString());
if (player == null) return;
// 服务器上也执行移动
player.Velocity = velocity;
player.MoveAndSlide();
// 广播新位置给所有客户端
Rpc(nameof(UpdatePosition), player.Position);
}
}# 带客户端预测的网络角色
extends CharacterBody2D
@export var speed: float = 200.0
var _last_server_position: Vector2
var _has_server_update: bool = false
func _physics_process(delta):
# 处理输入(本地预测)
var velocity = Vector2.ZERO
if Input.is_action_pressed("move_up"): velocity.y -= 1
if Input.is_action_pressed("move_down"): velocity.y += 1
if Input.is_action_pressed("move_left"): velocity.x -= 1
if Input.is_action_pressed("move_right"): velocity.x += 1
velocity = velocity.normalized() * speed
velocity = velocity
move_and_slide()
# 发送输入给服务器
rpc_id(1, &"server_receive_input", velocity)
# 服务器广播位置更新
@rpc(authority, call_local = false)
func update_position(server_position: Vector2):
# 如果服务器位置和预测位置差距太大,进行校正
var distance = position.distance_to(server_position)
if distance > 5.0:
_last_server_position = server_position
_has_server_update = true
func _process(delta):
if _has_server_update:
# 平滑插值到服务器位置,而不是直接跳过去
position = position.lerp(_last_server_position, 0.3)
if position.distance_to(_last_server_position) < 0.5:
position = _last_server_position
_has_server_update = false
# 服务器接收输入
@rpc(any_peer)
func server_receive_input(velocity: Vector2, sender_id: int):
if not multiplayer.is_server():
return
var player = get_parent().get_node_or_null(str(sender_id))
if player == null:
return
# 服务器上也执行移动
player.velocity = velocity
player.move_and_slide()
# 广播新位置给所有客户端
rpc(&"update_position", player.position)房间和匹配系统
多人游戏通常需要"房间"系统:玩家先进入大厅,创建或加入房间,房间里的人一起开始游戏。
房间管理概念
游戏大厅(Lobby)
├── 房间 1(2/4 人)
│ ├── 玩家 A(房主)
│ └── 玩家 B
├── 房间 2(3/4 人)
│ ├── 玩家 C(房主)
│ ├── 玩家 D
│ └── 玩家 E
└── 房间 3(1/4 人)
└── 玩家 F(等待中)实现建议
Godot 内置的多人 API 适合做局域网联机或小规模在线对战(2-8 人)。如果要做一个有几千人同时在线的商业游戏,建议使用专门的后端服务:
- Nakama:开源游戏服务器,支持 Godot
- Photon(PUN/Fusion):成熟的多人游戏服务
- Mirror:Unity 生态的网络框架(Godot 有类似项目)
- 自建后端:用 Node.js/Go + WebSocket 自己写
性能优化建议
| 优化项 | 说明 |
|---|---|
| 降低同步频率 | 不需要每帧同步,可以每 3-5 帧同步一次 |
| 只同步变化 | 只有位置变了才发送位置数据 |
| 压缩数据 | 用二进制格式代替 JSON,减少数据量 |
| 插值平滑 | 客户端收到位置后用插值平滑过渡,避免画面跳动 |
| 区域划分 | 大地图上只同步附近玩家的数据 |
下一章
多人战斗的同步原理讲完了。下一章我们来做一种非常经典的多人游戏类型——纸牌麻将,它对网络同步的要求和卡牌类似,但规则更复杂。
