持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第14天,点击查看活动详情
本节开始研究客户端与服务端角色移动同步问题。
功能解析
多人游戏之所以是多人游戏,就是因为每个玩家的动作都可以被别的玩家看到,营造出一种大家在同一个世界的氛围。其中玩家移动的同步是最基础最重要的,它代表了玩家之间的远近关系,同时也划定了玩家影响世界的范围。
前期查阅了各种资料,没有找到能说很清楚的实现方式,全都是理论性的,本人水平有限无法落地。但是前几天看到了 2 天做了个多人实时对战,200ms 延迟竟然也能丝滑流畅? - 掘金 (juejin.cn) 这篇文章,文章里详细说明了在位置同步中可能会出现的问题,比如本端和其他玩家端的移动卡顿不流畅;同时针对这些问题提出了一个我觉得不错的解决方案,那就是 指令队列。
指令队列
文中提到了一个和解公式:
预测状态 = 权威状态 + 预测输入
其中 权威状态 可以认为就是服务器的状态,因为在多人游戏中服务器的数据才是权威的。 预测状态 是客户端表现的状态,因为客户端的状态送往服务器并得到确认需要时间,因此客户端为了让玩家角色不卡顿,先行表现,而不是等待服务器回应后再进行下一个动作,这样会极大影响玩家体验。 预测输入 即是客户端角色移动的状态变化。
可以看出,客户端只需不断将移动状态变化的事件存入一个队列结构,并在前端进行即时表现,收到服务器对某次状态变化进行确认后即将该事件从队列删除。整体上相当于一个 生产者——消费者 结构。
正常情况下这个方法可以保证玩家本端显示的效果非常平滑。但是当延迟较高的时候,这个指令队列有可能被其他玩家对当前玩家的一些动作所打断,比如战斗中的定身,这是就出现了本端预测与服务器权威状态的冲突。这时为了保证本端与服务器的权威数据一致,只能抛弃队列中的预测状态变化事件,结果就是玩家被拉回之前与服务器状态一致的位置。虽然这样看起来体验不是很好,但是这也正是延迟的代价。
服务器坐标计算
为了减少网络传输量,我计划目前只发送移动状态发生变化的事件,而不是固定时间间隔直接发送位移。比如加速度变化和速度变化。我打算在服务器实现变加速运动,但是目前为了简化就只考虑匀速运动。
当玩家运动状态变化时,就会发送数据包给服务器,服务器的 AOI 模块接受运动状态数据并存储,并且拥有一个定时任务,用来定时更新服务器上玩家角色的位置数据。
计算原理是与单个玩家关联的 AoiItem 进程在存储运动状态数据的同时,也会存储客户端和服务器的时间戳,目前客户端时间戳没有使用,打算在未来用于验证。当定时任务触发时,就可以根据现在和之前存储的服务器时间戳得到一个时间段,有了时间和速度,就可以计算出当前时刻玩家角色的坐标。计算完成后再将当前时间戳覆盖到存储的时间戳上,预备下一次定时触发。
简要实现
客户端移动提交&指令队列
客户端定义一个新的 Component,实现一个用于发送移动状态的方法,以及一个队列结构:
UCLASS( ClassGroup=(Custom), meta=(BlueprintSpawnableComponent) )
class MP5DEMO_API UNetEntityActionActorComponent : public UActorComponent, public FMessageDelegateBase
{
GENERATED_BODY()
public:
// Sets default values for this component's properties
UNetEntityActionActorComponent();
// 角色移动组件引用
UPROPERTY(EditAnywhere, BlueprintReadWrite)
UCharacterMovementComponent *MoveComp;
// TCP 消息模块引用
UPROPERTY(EditAnywhere, BlueprintReadWrite)
AMP5DemoTcpSocketConnection *TcpConnection;
// 发送移动消息
UFUNCTION(BlueprintCallable)
void SendMovement(FVector3f Location, FVector3f Velocity, FVector3f Acceleration);
// 获取指令队列长度
UFUNCTION(BlueprintCallable)
int32 MoveQueueLength();
// 服务器返回响应处理回调
virtual void OnMessageReceived(Packet *P) override;
protected:
// Called when the game starts
virtual void BeginPlay() override;
// 指令队列以及手动计数
TQueue<Packet*> MovementQueue;
int32 QueueCounter = 0;
public:
// Called every frame
virtual void TickComponent(float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction) override;
};
可以看到,这个类同时还继承了 FMessageDelegateBase 类,用于向 AMP5DemoTcpSocketConnection 注册消息处理函数。下面的指令队列使用了利用额外变量进行技术的方法,因为 UE 的 FIFO 结构 TQueue 内部实现是链表,没有提供默认的长度方法,因此我就用了一个 int 变量,当添加元素时就为其 +1,当删除元素时就为其 -1,从而实现手动的长度记录。
指令队列内存放的元素类型为 Proto 类型,方便在回调时获取消息响应 ID,以及如果预测冲突的话恢复之前的运动状态。
服务器移动计算
服务器端为了实现高效计算,数学运算的部分同样放到了 NIF 里,目前与 coordinate_system 放在一起。代码:
pub fn calculate_coordinate(
old_timestamp: i64,
new_timestamp: i64,
location: Vector,
velocity: Vector,
) -> Vector {
let mut result: Vector = location.clone();
if velocity == (Vector{x: 0.0, y: 0.0, z: 0.0}) {
result = location;
} else {
// 获取时间段并换算单位为秒
let time = (new_timestamp - old_timestamp) as f64 / 1000.0;
result.x = location.x + velocity.x * time;
result.y = location.y + velocity.y * time;
result.z = location.z + velocity.z * time;
}
return result;
}
在 AoiItem 进程内创建定时任务,按固定时间间隔进行计算:
# 创建定时器
defp make_coord_timer() do
Process.send_after(self(), :update_coord_tick, @coord_tick_interval)
end
# 定时任务消息处理
@impl true
def handle_info(
:update_coord_tick,
%{system_ref: system, item_ref: item, movement: movement} = state
) do
# 获取新位置
new_location = if movement.velocity != {0.0, 0.0, 0.0} do
new_location = CoordinateSystem.calculate_coordinate(movement.server_timestamp, :os.system_time(:millisecond), movement.location, movement.velocity)
CoordinateSystem.update_item_from_system(system, item, new_location)
new_location
else
movement.location
end
# 更新新位置至状态
{:noreply, %{state | coord_timer: make_coord_timer(), movement: %{movement | location: new_location}}}
end
下一步
下一步将聚焦于客户端在场景中生成其他玩家,向多人联机再迈出一步。