多人TPS个人学习笔记

写在前面

这篇文章是斯蒂芬的多人tps教程的个人学习笔记,欢迎大家有问题和我讨论
原文链接:https://r4atlqgm2n.feishu.cn/docx/D3nrdqjM7oqXGRxJ9elcUX1knDf
我的GAMES-104学习笔记:
https://r4atlqgm2n.feishu.cn/base/bascndYO6BOXZnmwouCCkZsZHae?table=tblKNgJrEwlnDYr0&view=vewoZU06st
其他学习笔记:
https://r4atlqgm2n.feishu.cn/base/bascn9uVSvvhHbewhfJM2NugUrc?table=tbldUVzJUolbSRI1&view=vewj248gqh

创建项目


game空项目,选择CPP

项目目录下新建Plugins,把我们的MultiplayerSessions放里面

打开steam插件

添加免费资产


选择这个包,是因为他不支持UE5,我们可以趁机学习怎么在UE5中加入以前版本的资产(现在这个包支持5.0)

C++动画蓝图


继承自AnimInstance,使用meta语法访问私有变量,将基础的属性tick放到C++中,角色不随Controller旋转,但如果有移动输入,会把角色转向那个方向。

Seamless Travel⭐⭐⭐⭐⭐


服务器调用ServerTravel,所有客户端都会调用ClientTravel

网络同步角色⭐⭐⭐⭐⭐


组件效果

创建武器类

创建武器类,检查是否Authority
[图片]
网络同步的代码,都需要bReplicates =true
[图片]
自定义武器枚举,用UMETA限定编辑器时显示的名称
[图片]
只在服务器上绑定重叠事件
[图片]
这样子写,服务器重叠只有服务器可见,客户端重叠也只有服务器可见

变量网络复制⭐⭐⭐⭐⭐

[图片]
给需要网络复制的变量加上宏定义
[图片]
接着,重写虚函数,复制这个变量
[图片]
调用的时候复制变量
[图片]
书写一个inline函数
[图片]
这样,客户端运行时,会在服务器和所有客户端上都看见这行字
[图片]
只在owner上复制这个变量,这样做的话,当前客户端和服务器都能看到这行文字(Pickup)
[图片]
定义变量复制事件,在变量被复制时执行,这样我们只在捡到武器的客户端上看到这行字
[图片]
OnsphereOverlap仅仅发生在服务器上
[图片]
这样,走出去时就不会显示ui了
变量复制只能从服务器传到客户端,不能反向传播

手动设置网络延迟⭐⭐⭐⭐⭐

[图片]
模拟ping,除此之外还可以模拟丢包等

战斗组件

[图片]
定义战斗组件
[图片]
互为朋友,这样角色就能随意访问战斗组件的所有函数,包括private和protected,捡起武器时,要确定是否是服务器。

多播拾起武器Rpc(1)⭐⭐⭐⭐⭐

[图片]
reliable意味着会进行确认
[图片]
Rider会自动加上Implementation,vs需要自己加
[图片]
如果是客户端,使用RPC的方式调用这个函数,但是会发现我们的UI没有隐藏
[图片]
这是因为setPickupWidget没有在客户端之间广播
[图片]
只有服务器上会产生overlap这种事件,一些例如meshAttachToPlayer这种操作,是自动复制的,我们不需要额外书写代码。RPC指远程程序调用,在这个例子中,我们让客户端上的装备事件在服务器上执行

多人游戏动画蓝图⭐⭐⭐⭐⭐

[图片]
这样是行不通的,因为EquippedWeapon并没有在客户端上复制
[图片]
这一段非常难理解,在我们有三个角色的情况下,假设我们在服务器上,操作一个客户端的角色捡起武器,这个角色的EquippedWeapon被设置上了,但这个只在服务器上发生了,服务器上这个客户端的动画蓝图会发生状态改变,播放持枪动画,但这边没有复制到对应客户端上
shift+?全选一行

添加下蹲

[图片]
移动组件内置了Crouch()以及对应的网络复制函数,按下左Shift,什么都不会发生
[图片]
因为要在移动组件中打开

瞄准

[图片]
和蹲基本上一样,但这个变量没有网络同步,无法在别的客户端上看到变化。网络同步后,可以在服务器上瞄准,但无法在客户端上瞄准。
[图片]
需要为客户端编写RPC函数
[图片]

Aim Offset⭐⭐⭐⭐⭐

[图片]
手动制作AO动画
[图片]
把AO动画都设置成Additive,选择meshSpace,参考CC动画
[图片]
在这个角色中,spine_01分开上下半身
[图片]
选择spine_01
[图片]
使用AO,静止不动的时候,我们希望人物的行为是:
可以朝四周看,超过一定角度之后移动脚
[图片]
不动时,我们需要yaw和pitch的AO,移动时,我们只需要pitch的AO。
注意这里不能用GetControlRotation,因为一台机器只有一个Controller,在联网编程时要使用GetBaseAimRotation()。
TODO:从服务器上捡起武器,Pitch值同步了,但Yaw值没同步
在客户端上捡起武器能把Pitch同步,但Yaw值不行
课程写到这里,确实是会有这个问题的

TurnInPlace

[图片]
新建TurnInPlace枚举,在Character类里处理这个枚举值
[图片]
使用分层混合解决只有一个转身动画,但是需要在转身时区分瞄准和不瞄准,填spine_01即可
[图片]
动画飘在天上,可以直接拖下来,加一个key即可。不喜欢动画里有key可以直接导出资产

旋转根骨骼

[图片]
使用旋转根骨骼节点,稍加改动Character.cpp即可实现。
实际上就是人物胶囊体跟着controller在运动,而旋转根骨骼得到了Yaw值将旋转值移回去了,而上半身的AO依然生效,插值一定时间后将StartAimingRotation清零,停止旋转,AO参考的是StartAimingRotaion,所以表现正常。

倾斜跑BlendSpace

[图片]
[图片]
制作倾斜动画
[图片]
自己制作Lean动画
[图片]
在持械时,我们不需要朝向旋转,我们需要始终看向前方
[图片]
动画蓝图里不需要复制倾斜这种变量,是因为Actor的Rotation是引擎自动复制的,所以在各个客户端上计算出来的结果都是一样的。
[图片]
从-180到180的过渡非常奇怪,因为中间过渡了非常多中间动画,解决办法是插值,不能使用float插值,使用Rotator插值,UE会帮我们找到-180到180的最短路径,得到正确结果。
[图片]
负值可能会打包出错,因为虚幻在网络传输时会把float变成无符号数,压缩至5Byte。

自制八方向移动动画

[图片]
把向前跑动画根骨骼旋转45,spine01反向旋转45即可

FABRIK IK⭐⭐⭐⭐⭐

P53 FBARIK IK
[图片]
在突击步枪的根上加一个socket,命名需要统一
[图片]
计算应有位置
[图片]
这种情况下End Effector是我们的右手,Solver是我们的左手,RootBone选择leftArm,这将决定我们的算法在哪停止

网络更新频率⭐⭐⭐⭐⭐

[图片]
这两个分别是正常情况下网络同步频率和actor不活跃情况下的网络同步频率,在项目中,我们可以手动设置网络延迟,也能手动降低网络同步频率来测试网络不稳定情况下的游戏表现
[图片]
更改服务器tickRate

自制八方向动画

[图片]
使用向前跑动画,只旋转spine以上即可

添加声音

[图片]
动画通知中的声音会自动复制,不需要额外操作

抛物子弹

[图片]
基本设置

武器开火

[图片]
cpp中接受到是否按下左键的布尔值后,在CombatComponent中处理开火逻辑
[图片]
一个蒙太奇即可处理两个开火动画
[图片]
简单选择逻辑
[图片]
开火动画和声音并没有在网络上复制,这个需要使用MultiCastRPC解决

多播武器开火RPC(2)⭐⭐⭐⭐⭐

https://docs.unrealengine.com/4.27/zh-CN/InteractiveExperiences/Networking/Actors/RPCs/
[图片]
这种情况下,在客户端上捡起武器,只有服务器能看见开火动画和音效
这里不能使用变量复制,变量复制在变量发生变化时自动赋值,我们的自动武器开火时并不会有变量发生变化
[图片]
使用MultiCastRPC
[图片]
服务器和客户端上按下按键,都会触发RPC多播事件,在所有客户端上运行

准确发射Projectile⭐⭐⭐⭐⭐

[图片]
UE自带函数,可以获得屏幕中间点的方向和第一个检测到的位置,填充到两个引用传递的参数里,并且返回一个bool。
[图片]
我们获得了屏幕中间的世界坐标和朝向,可以利用这个做射线检测,常量可以进行宏定义,减少代码中的数字量
[图片]
对于子弹,可以这样定义,好处是可以接收任何派生自这个类的对象
[图片]
写完之后唯一的问题是,我们的射击是基于我们的viewport的,这意味着,simulate结果是错误的。我们要确保只在server上生成Projectile,然后复制到所有Client。
[图片]
只有服务器上能生成子弹,然后复制到所有客户端,将子弹也设置成网络复制的,现在客户端上也有子弹,但是因为目标没有复制到所有客户端上,开枪时子弹方向是不对的

网络同步正确方向⭐⭐⭐⭐⭐

[图片]
使用FVector_NetQuantize进行网络传输优化,FVector_NetQuantize是FVector的子类,削减了精度,提高了网络传输速率

命中事件网络同步

[图片]
先声明回调函数,可以点击CollisionBox的Hit,查看需要什么参数,WorldContextObject指的是一个世界中的物体,一般传入this
[图片]
这样写,特效和声音在所属客户端上播放,我们需要在所有客户端上播放。
Destroyed()在gameplay过程使用。
我们实现多播特效和声音的最简单方式,是在Super::Destroyed()后书写我们的播放声音和特效逻辑。
对于一个子弹,实际上我们不需要mesh,各种游戏中也没有mesh,只需要一道光柱(Particle)即可

弹壳自发光效果

[图片]
按住4,创建一个Vector4D,然后保持各个通道都是0,接着把他转化为参数,叫Emissive
[图片]
打开这个材质实例的Emissive,我们可以override这个值
[图片]
调成喜欢的颜色
[图片]
对于Casing的生成,我们没有进行变量复制,实际上我们也不需要复制这个变量到所有客户端,只需要本地看到表现,我们需要在他触地之后的一会将它Destroyed。

音频编程

[图片]
可以创建一个sound cue,然后将所有cue随机选择
[图片]
创建音频衰减,可以避免距离很远时仍能听见

自制HUD

[图片]
使用GenerateBody使得反射系统能和这个结构合作,USTRUCT使得这个结构能在蓝图中使用
BUG:只有服务器上能瞄准
最后发现蓝图里设置了 combatComponent不能同步

修正枪口朝向

[图片]
骨骼上的SocketName不是大小写敏感的,hand_r==Hand_R。计算出右手应有朝向后,在动画蓝图中做解算
[图片]
我们只做本地结算,不需要结算其他端上结果。

瞄准缩放

[图片]
改变摄像机的景深和光圈值,使得我们在瞄准近处和远处物体时不会出现模糊。

UE CPP接口⭐⭐⭐⭐⭐

[图片]
自动创建的.h里有U开头和I开头的两个接口,在我们使用时,角色应该继承I开头的接口。
Interfaces
[图片]
询问一个类有没有继承接口的三种方法
[图片]
注意,这里询问是否继承接口,使用U开头的版本。这种情况下,接口里不需要实现任何函数,如果拥有这个接口,我们就把准星改成红色。

隐藏人物模型

[图片]
判断与摄像机的距离,太近时隐藏,超过时恢复。

多播受击反应(RPC(3))⭐⭐⭐⭐⭐

[图片]
为四个受击动画做叠加处理
[图片]
叠加处理后将这四个动画塞进同一个montage里,受击时进行选择即可。服务器可以命中客户端,客户端无法命中服务器,我们需要对这个动画进行multicastRPC。
[图片]
选择Unreliable,这个不必重要,偶尔丢包不影响,可以提升网络表现。Hit只在服务器上发生,发生时调用MulticastRPC
[图片]
新增一个通道,使用实际的Mesh进行命中检测,便于实现爆头等伤害数值不同。
[图片]
ECC_1代表我们刚刚创造的碰撞通道,我们可以在Blaster.h中对这个内容进行替换。

解决SimulateProxy抖动⭐⭐⭐⭐⭐

P81_Smooth Rotation for Proxies
[图片]
这个是因为我们本地使用了Rotate Root Bone,我们在网络上并不会以本地的速度tick,所以会出现抖动问题。
[图片]
我们需要对simulateProxy进行额外处理,单独写一个函数来进行RotateRootBone。
[图片]
我们不能每帧调用这个函数,因为网络传播tick率要低于本地tick率,每帧调用实际上并不会起作用
[图片]
正确的做法是重写这个函数,角色的运动发生改变时,这个函数会执行一次。

全自动开火(Timer)⭐⭐⭐⭐⭐

[图片]
使用Timer进行全自动开火

UE_GameFrameWork⭐⭐⭐⭐⭐

[图片]

  • GameMode只存在于服务器上
  • GameState存在于服务器和客户端上
  • PlayerState存在于服务器和客户端上
  • PlayerController只存在于OwingClient
  • PAWN存在于服务器和客户端上
  • HUD/WIDGETS只存在于OwingClient
    [图片]
    GameMode定义了默认的类,游戏规则(玩家死亡和重生),比赛状态,热身时间,比赛时间。ServerOnly。
    GameState定义了比赛的状态,得分最高的玩家,领先的队伍,队伍得分,PlayerStates数组。
    PlayerState定义了玩家得分,击败数,弹药数,属于哪个队伍
    [图片]
    PlayerController可以轻松访问HUD
    [图片]
    服务器和客户端类存在情况

CPP编写UI

[图片]
用到了自带的ToText和四舍五入函数

造成伤害⭐⭐⭐⭐⭐

[图片]
使用UE自带的ApplyDamage,需要一个instigator,这个是发起者的意思,接收一个PlayerController,
Causer接收发起伤害的Actor。我们暂时没有自定义伤害类型,这里使用UDamageType::StaticClass即可。
变量复制比RPC效率高得多,应该尽量避免使用RPC。ReceiveDamage只会发生在Server上,在这里进行一次PlayHitReactMontage,OnRep_Health只会发生在Client上,我们在这里也进行一次PlayHitReactMontage,避免了使用RPC

事件种类

对于事件有三种:Multicast (多路广播), Server (服务器), 以及 Client (客户端)。勾选 多路广播,该事件应在服务器上进行调用,在服务器上执行然后自动转发到客户端。 勾选 Server,该事件应在客户端调用,随后仅在服务器上执行。 勾选 Client,该事件应在服务器调用,随后仅在其所拥有的客户端上执行。

RPC细节

RPC 函数非常有用,可允许客户端或服务器通过网络连接相互发送消息。 远程调用函数可设置为 Reliable 或 Unreliable,其中 Reliable 调用必定会发生,而 Unreliable 调用可能会在网络繁忙时被丢弃。大多数处理装饰视觉效果的远程调用函数应设置为 Unreliable,以避免过多占用网络。
远程调用函数主要包括 3 种类型:Multicast 广播、Run on Server 在服务端执行 和 Run on owning Client 在客户端执行

GameMode⭐⭐⭐⭐⭐

[图片]
继承自gamemode而不是Gamemodebase,因为gamemode有更多的已存在的函数和功能。
[图片]
在玩家受伤时与GameMode联系起来
[图片]
动画蓝图中对是否死亡做处理,成功在服务器上播放,接下来的目标是在所有客户端上播放
[图片]
将它声明为multicastRPC即可,要保证Reliable

使用Timer完成重生

[图片]
[图片]
Timer的第二次应用

DissolveMaterial⭐⭐⭐⭐⭐

P91
[图片]
使用Mask模式,将得出的结果作为Mesh上的溶解,把溶解程度和发光倍数提升为参数,使用1-x的数据作为UV Data,UV就是材质的XY坐标
[图片]
[图片]
使用UE自带的Texture制造边缘锐利效果,将这段代码加到角色材质上即可
[图片]
使用CPP定义Timeline并且使用Curve控制溶解程度
[图片]
增加轨道
[图片]
使用TEXT设定参数

机器人特效

[图片]
设置粒子寿命

PlayerState⭐⭐⭐⭐⭐

[图片]
PlayerState储存玩家状态相关的东西,例如分数。Score是PlayerState自带的东西,这意味着他会自动复制。
PlayerState在服务器上在BeginPlay之前就已经初始化完成,在客户端上,需要重写OnRep_PlayerState()赋初始值
[图片]
给空指针加上UPROPERTY,他会是一个真正的空指针,不会发生内存访问错误
[图片]
需要在捡起武器时就设置正确的子弹数量,我们可以重写OnRep_Owner

有条件变量复制⭐⭐⭐⭐⭐

[图片]
在复制玩家储备弹药这种变量时,我们无需把他复制到所有客户端,只需要复制到所有者的客户端即可。
[图片]
defaultMAX的意义是,我们可以直接查看他等于几,来告诉我们一共有多少个枚举值
[图片]
TMAP无法网络复制,需要想出别的办法

换子弹问题

[图片]
最优解
[图片]
使用特殊语法初始化倒计时,保证01也会有两位数字

同步客户端和服务器时间⭐⭐⭐⭐⭐

[图片]
需要以服务器为主,加上RPC时延
[图片]
我们无法获得两边的时间,只能使用两个 1/2RTT进行估计,这个技术上被称为非对称往返时间。
[图片]
[图片]
需要实现客户端向服务器的询问,服务器对客户端的答复两个RPC函数

GameMode⭐⭐⭐⭐⭐

p111
[图片]
GameMode相比于GameModeBase,多了MatchState以及相关的切换函数
[图片]
MatchState包括很多状态,我们也可以自定义我们自己的状态。
[图片]
在有一大堆变量需要复制时,我们可以不使用变量复制,直接使用一个ServerRPC完成任务。
BlasterGamestate⭐⭐⭐⭐⭐
[图片]
维护一个数组来表示现在得分最多的玩家

Make Smoke Trail⭐⭐⭐⭐⭐

[图片]
为我们的火箭轨迹创建一个Material和MaterialInstance
[图片]
[图片]
创建Emitter,定义随机的旋转,随机的图案和随机的持续时间,颜色随时间改变。

延迟补偿⭐⭐⭐⭐⭐

P159
[图片]
从客户端一侧先进行预测,发送这个行为到服务器,再广播给其他客户端
[图片]
内插值,在别的客户端,会取两次数据的中间值。
[图片]
外插值,在下一次值到来时假设目标接着往前走
[图片]
UE同时使用两种插值,并且在需要纠正时使用Rubber-banding.

Sever-Side Rewind

[图片]
在判断命中事件时,需要和之前Rewind的帧进行对比,判断是否真正命中了目标。
[图片]
可以从PlayerState中获得Ping,这个值被UE/4,我们需要乘回去。
[图片]
1.倒回命中时间,射线检测玩家的碰撞盒子。
2.确认命中后发送请求,重置所有盒子。

预测子弹数量

[图片]
客户端侧预测的小应用

服务器倒带实现⭐⭐⭐⭐⭐

[图片]
我们需要存下来前几帧角色的位置,精确程度按需求而定

构建FramePackage

[图片]
为各个部位制作用于Rewind的碰撞体
[图片]
构建一个箱子的信息,frame的信息包括箱子的信息和名称以及对应的骨骼名字,这个使用TMap实现。
[图片]
每帧把这个信息保存下来
[图片]
最好的解决方案是无环双链表
[图片]
每帧存储,并且可以规定丢弃时间

InterpTo原理

[图片]

记录往返时间

[图片]
我们需要估计隔一段时间记录一次往返时间,为我们的服务器倒带函数提供参考。

PostEditProperty

[图片]
重写这个方法来保证各种属性的蓝图和C++一致,只在Editor情况下编译这一段

ProjectileRewind⭐⭐⭐⭐⭐

[图片]
[图片]
非常复杂,需要处理Server-Client,Locally-notLocally,useSSR-orNot。这一段是,projectile武器,在收到开火指令后生成子弹的处理。减少ssr的调用次数。
这样处理,在服务器上的结果是一样的。
如果在客户端时,如果不使用SSR,我们会在网络延迟后发射我们的子弹。我们不会生成本地的子弹,会等待服务器返回复制的子弹。
如果使用SSR,我们会在客户端上立刻看见SSR子弹,随后在服务器和别的客户端上看见非复制的非SSR子弹。
这样操作能在客户端上立刻看见结果,提升可玩性,并且也带有正确的服务器结果。

作弊与验证⭐⭐⭐⭐⭐

作弊方法

CodeInjection

[图片]
通过高级语言查看运行时的内存,找到值之后使用poke命令修改这个值。或者使用.dll库注入游戏中

网络数据包伪造

[图片]
通过截下客户端发给服务器的包,替换其中一些数据为优势数据实现作弊效果。

利用Bug

[图片]
例如进入一些不允许进入的区域,你能攻击Boss但Boss不能攻击你。

MemoryEditing⭐⭐⭐⭐⭐

[图片]
利用一个应用程序来监控游戏内存,将一些值修改成优势数据。

Validation

[图片]
UE自带验证函数,用于阻止上文所说的MemoryEditing方法。
对于前两种办法UE会自动阻止,不太可能发生。

项目实战

[图片]
在beginPlay中把开火速度改得超级大
[图片]
利用UE自带Validate函数,如果返回false,会直接将玩家踢出游戏。

记录退出游戏玩家

[图片]
客户端退出游戏时,应该有一个完整的流程。
[图片]
角色,GameMode,菜单三个类之间需要配合。