Tizeng's blog Ordinary Gamer

Gaia笔记4——周版本更新

2021-01-27
Tizeng

GC

之前更换模型很多次之后出现的内存暴涨4G的问题再次出现,尽管已经将更换Mesh改成了Destroy场景中的Actor再读表重新创建,怀疑是多次操作后积累了过多需要gc的资源,导致一次清理时消耗过多内存。引擎中很多接口会让我们传一个WorldContext进去,可以选择直接传目前类型的this指针,或者TransientPackage进去。

系统提示信息(2021.1.28)

最开始我想根据三个枚举值去抽象每个提示消息,第一部分、第二部分、格式,c++把这三个类型通知到lua,然后lua去显示,做完交互的提示就发现这样设计很僵硬,如果提示所需的信息量不只三个,就需要重新定义通知的delegate,而这个不确定因素是不可控的,所以肯定不能这么做。在友哥的建议下先定义一个NoticeBase类,其中定义一个GenerateNoticeContent的接口,需要显示提示的地方根据需求使用相应的派生类。

提示出现本该在自己客户端弹出的提示弹到了别人账号上的问题,原因是服务器用GetController得到的PC可能并不是客户端自己的PC,谁需要弹提示就要找到对应的PC去弹。

设置界面(2021.2.4)

灵敏度等的设置条

UE4有Slider组件作为拖动条,更新和读取值十分方便,但表现很基础,策划需要跟随thumb显示的数字,要实现这个效果,得根据Slider长度和位置计算出thumb的初始位置,然后用其当前百分比算出一个当前位置,并把数字组件设到那个位置,而且每次SliderValue变化的时候都去更新。

有很多自适应的需求,在搭建小组件蓝图时(如可拖动进度条),过度使用Hbox和Overlay会影响自适应的效果,造成一些蛋疼的问题,有时候直接另加一个Canvas就完了。对于需要保持长宽比缩放的widget,可以考虑使用ScaleBox,然后设置合适的四周锚点,或者用其他容器包一下。

将界面接入灵敏度等设置

摄像机逻辑在GaiaCameraControlComponent中,先理一下触屏控制逻辑:首先在PlayerController(简称pc)的InputComponent上绑定(用BindTouch而非BindAction)了OnTouchMove等回调,例用pc的delegate通知lua处理,有触摸事件后会知道触摸的屏幕location,和此次点击最初的location比较过后,将变化的X值和Y值分别通知pc,pc负责用delegate广播yaw和pitch的变化,需要关心它们变化的component(比如这里的CameraControlComponent)会在BeginPlay时在pc的相应delegate上注册自己的回调函数(监听),然后处理逻辑。

镜头转动

  • InputComponent是定义在Actor中的成员,Controller本身继承自Actor
  • 多播(multicast)delegate指的是能绑定多个函数的delegate,在调用Broadcast后一并执行

储存玩家设置

使用引擎中自带的SaveGame Object储存设置信息。

滑动条手感优化

ue4自带的Slider的功能很简单,只是一个可以拖动的条,点击其他位置会把thumb从当前位置设置到点击位置,但策划并不想要这样的效果

第一次打开设置界面的情况

此时会检测不到本地储存的SaveGameObj,直接创建一个,使用默认值初始化ui,然后储存该Obj。

与其他界面的冲突

设置界面打开时会隐藏hud,如果此时玩家死亡并且有复活机会,复活ui会与设置界面重叠,如果玩家复活,hud会因复活被打开,这是逻辑出现了冲突,打开设置界面隐藏了HUD,但死亡也会隐藏,然后复活时再打开,要解决这个问题只需要区分隐藏的层级,死亡时并不需要隐藏所有HUD,至少要留下退出按钮,因此只隐藏大部分HUD,而设置界面则隐藏所有HUD,包含死亡隐藏的部分,这样就不会有冲突了。

玩家死亡后可能会出现复活按钮,这部分逻辑原本在c++,现在要移到lua,相当于c++通知lua,在PC中定义一个delegate就行,后来发现直接加在死亡通知的delegate里面就行。

总结

这次做这个界面需要用很多通用组件,如显示数字的滑动条、多项选择条、确认界面等,遇到最多的问题是自适应,当外界大小变化时,有的组件要X方向同时变大,Y方向不变,而有的是部分变部分不变,还有的按比例变,需要活用canvas、scalebox和四角、两边锚点。

调数字位置时与Slider的Handle位置偶尔不能同步,MinValue越大不同步现象越明显,查看源码之后发现SliderValue在处理前减去了MinValue,我想当然的以为他不会去减,导致我计算出的百分比和Slider计算的不一致(能看源码还是好)。

做按下按钮持续增长减少时遇到按钮OnReleased事件不正常触发,是由于按钮勾选了IsFocusable导致。

滑动条右边mask是一串点点点的图片,直接设置size会使之变形,因此要把image的Tiling设置为horizontal,图片的锚点设在右端,AlignmentX设为1,这样可以在size变化时让其右端不动,从左边开始缩放,但Tiling默认是从左往右填充,没有方向的选项,会有不需要的移动效果,想让其从右往左填充,只需要将其ScaleX设为-1。

很多交互条件是以图片资源的大小为界限,做判断时应该直接去拿相应资源的大小,而不该去手动设置阈值,这样无论图片如何更换效果都保持一致。

widget有一个Clipping设置以前一直没有注意,直到这次发现ScrollBox会裁掉超出区域的ui而VerticalBox不会,查了一下之后才发现原来是可以设置的,ScrollBox默认超出边缘的child都会被裁掉。但其实并没有卵用,因为如果设置成不clip,那里面的内容就会超出ui区域。

lua修改灵敏度数值储存后,需要通知CameraControlComponent,实际上就是lua通知c++,在pc中定义一个delegate,注册回调,然后lua在需要时通知时Broadcast就行。

设置界面应该做成按配置去生成grid的形式,否则每次都要写相同的逻辑,之前没有直接这样做的原因一是时间紧来不及,二是美术需要看效果。

更新(2021.7.16)

设置界面有很多相同的逻辑,如TopBar、恢复默认、返回按钮注册、刷新等,现在把这些通用的逻辑抽象成了一个基类,各个设置界面都去继承,这样可以省去很多重复的代码。后面还需要加红点的逻辑,unlua暂时不支持多继承,打算直接用lua的模块来做。

掉落物整合进交互(2021.3.15)

出现测试场景中没问题但进副本不触发问题,发现是掉落物actor在spawn时会有服务器和客户端区分,归属玩家的掉落由客户端生成,无归属的掉落由服务器生成然后同步给所有人。

掉落客户端表现是SpawnActor后,从初始位置用一个TimeLine去更新,直到到达服务器给的EndPosition。

画质设置(2021.3.25)

大部分画面设置的接口都定义在GameUserSettings里面,一般分0-4五档,需要设置哪个直接调用就行了。后面有需要可以继承一个GaiaGameUserSettings,用来重写一些设置的实现或者增加新的接口。

GM命令(2021.3.31)

游戏中按下\`输入gm命令的实现很简单,只需要在CheatManager中加上对应的方法,输入与方法名一样的名称与参数就会执行,策划另外要求了一套GM面板,点击按钮后会在专门的界面上自动输入对应的gm命令,点击执行后激活功能,这就要求我们自己解析输进去的字符串,并为其绑定函数,同时还要拿到参数,调用时一并传入,传入的方法是将所有参数放进一个table,然后调用函数时直接把这个table传进去。

合作信息提示(2021.4.26)

队友1保护了队友2

在僵尸死亡时先判断是否满足保护条件(如周围有玩家),如果有则拿到击杀者名字,然后对所有玩家广播信息。

倒地提示

倒地后通知所有人玩家倒地,并给未倒地玩家显示帮助倒地玩家信息。这里的重点就是如何区分自己是不是倒地玩家,通常来说需要通知所有人会使用多播RPC函数,这样所有客户端都能收到,然后比较传入的PC和本地的FirstController来判断是不是该玩家,从而弹不同的提示(xxx救了你/你救了xxx)。还有一个方法,在Player类中定义一个static delegate用来通知倒地的状态,然后BeginPlay的时候注册,如此一来这个delegate就独立于各角色存在,每次Broadcast的时候会通知当前所有角色(实例),因此可以直接比较this和传入的角色指针来判断倒地者、救起者、击杀者,最后调用Client函数为其客户端显示提示。

尸潮提示

尸潮由触发器触发,有一个专门的蓝图类,在其中的OnActive事件中显示提示就行了。重要的是显示提示的方法,之前有些提示是用在提示类中为每个子功能写一个专门生成提示信息的方法,需要改提示的时候就调用,然后显示,但这样就很冗余,相当于每次要新提示就要加新方法,比较好的做法是定义一个通用的生成字符串的方法,然后创建一个继承自这个类的蓝图,在蓝图中配置表里的id,然后编辑器中定义一个ClassRef的基类变量,需要哪个信息就选哪个类,然后调用通用的方法即可。

特殊僵尸出现提示

直接在npc蓝图上做。

Hunter相关提示(2021.5.16)

这块内容很关键,涉及到行为树和蒙太奇,以及GA。

hunter抓人是个技能,由行为树释放,专门的GA文件处理技能逻辑,当NPC死亡时会停止行为树,也就是停止释放技能。GetAbilitySystemComponent可以在技能激活和停止时注册回调,记录当前所释放的技能指针(UGameplayAbility*)。当NPC死亡时,判断击杀者是不是玩家,然后尝试用Cast将当前所释放技能转成抓人的技能,如果成功则说明此时应该显示救人提示。

抓人这个技能是由行为树释放的,怪身上配的行为树由Spawn的Actor决定,它会用SpawnAIFromClass方法来生成怪,有关AI的内容还需要花时间统一整理。

系统提示优化(2021.4.25)

之前系统提示写的很急,没怎么考虑扩展性和可读性,直接在代码里显式按id读表数据,令人头大。

友哥提供了一个以蓝图为基础的思路,定义Notice类时,将id作为一个成员变量,由外部配置,Generate方法直接用这个id去读表返回内容。为不同的功能派生该类的子类蓝图,在蓝图中配置,然后在需要的地方生成实例,调用Generate方法生成内容,显示提示。比如尸潮提示,尸潮通过触发器触发,触发后会调用OnActive接口函数,我们只需要在这里显示通知就行了,为了知道生成的是哪个类,还需要增加一个蓝图ref变量,然后选择Notice类,再将默认值设置成配置好的子类蓝图,这样生成的实例就可以按我们配置的数据Generate出提示内容,而生成的实现在类的定义中。

基于上面的思路,把交互提示也优化一下,以前三种交互物:弹药箱、医药包和手雷的交互已满提示是直接写在交互技能触发时所进行的逻辑判断中,这样很不优雅,每种类型的交互都有一个蓝图继承自TT_Interaction,里面处理自己的逻辑,交互技能的逻辑判断只需要知道这次交互成不成功,至于成功或失败后发生什么事情,在交互蓝图里面做就行了,这样可以一定程度上解耦交互过程和交互结束的代码。

蓝图重载基类的方法,其实是生成了另一个方法(一般以K2_开头),基类该方法执行的时候最后会检查是否实现了这个方法,如果是则调用。

交互已满提示优化后出现自己的提示跑到别人那里去的问题,原因是使用Controller调用显示通知在服务器执行,服务器用GetFirstPlayerController拿到的Controller是不确定的,要定义一个Custom Event,然后把Replicate设为Client,再执行显示通知逻辑。目前蓝图的函数还不支持设置Replicates。

队友描边(2021.4.29)

中间插的小功能,TA提供了显示角色描边的接口,只需要客户端判断一下是否是自己,然后调用就行。

如何区分队友呢?目前是拿Actor身上的Role成员变量来判断(文档在这)它和RemoteRole本身是储存该Actor是否由当前引擎直接管理的枚举,如果RoleROLE_Authority,且RemoteRole为其他两种,则说明当前的引擎实例负责管理这个Actor并将其replicate到远端(Client)。但从客户端视角来看,Role其实是为ROLE_SimulatedProxy,而RemoteRole为ROLE_Authority,因为服务器才是该Actor的实际管理者,客户端只是收到了更新它的通知,并在本地让它尽量和服务器上的行为一致。服务器每次更新Actor信息可能会有间隔以节省带宽和CPU,如果客户端只是每次收到更新通知才去更新位置就会显得卡顿,因此在每次更新间隔中要做推断(extrapolating)。

ROLE_SimulatedProxy:标准的模拟方式,客户端在更新间隔中根据服务器通知的最新速度朝目标方向移动(队友)

ROLE_AutonomousProxy:被Controller持有(Possessed)的Actor所用模式,它代表该Actor在推断位置的过程中还需考虑控制器的输入(自己)

死亡后移除

如果队友角色死亡,需要移除描边,使用之前的濒死状态回调就行。

近战攻击(2021.5.12)

按近战GA那边传过来的MeleeStart和MeleeEnd去正确的刷新ui状态即可,主要在于GA、蒙太奇、人物技能的关系,这部分会放在GAS笔记中。

滚动公告(2021.5.28)

需求是接收到服务器的消息之后,在屏幕上滚动显示该消息的内容,可配置为只显示一次或重复显示,如果是重复显示,则显示完后根据该消息cd继续显示,这个看似简单的功能比想象中的复杂。首先每条消息有优先级,消息队列会根据优先级排序,同样优先级的根据发送时间越早越前,这就需要我们每次发送的时候更新一下发送时间,否则发送时间早的消息只要cd到了,会永远挡住其他同优先级的消息。

实现上使用了lua的协程coroutine,因为这样可以最大化利用lua的闭包特性。处理队列中的消息时初始化公告grid,让它开始移动,读cd、更新发送时间等操作要它滚动到边界才会触发,但这些操作又需要用到消息的各种信息,我们就可以在这里创建一个coroutine,滚动完毕后resume,这样就免去了维护所有正在cd中的消息的麻烦,每个消息的cd计完后会自动执行我们定义好的逻辑。创建的coroutine虽然也要保存,但自始至终只需要存一个,因为同时只会有一条消息滚动,而同时进行cd的消息则是无法确定的。

-- set cd timer callback
local func_stop_cd = function()
    print("Roll cd over", data.content, data.frequency, os.date("%c", os.time()))
    self._existingContentTable[data.content] = nil
    table.insert(self._rollArray, data)
    self:SortRollData()
    if not self._isProcessing then 
        self:ProcessRollEffect()
    end
end
-- triggered when reaching the end position
self._co_roll_end = coroutine.create(function()
    print("coroutine begin", data.content, data.isRepeat)
    if data.isRepeat then 
        -- update send time
        data.sendTime = os.time()
        self:SetSendTimeByContent(data.content, data.sendTime)

        self:DeleteLocalContent(data.content, data.isRepeat)
        if data.frequency == 0 then 
            func_stop_cd()
        else
            local timer = UKismetSystemLibrary.K2_SetTimerDelegate({self, func_stop_cd}, data.frequency, false)
            -- table.insert(self._cdTimerTable, timer)
        end
    else
        self:DeletePushByContent(data.content, data.isRepeat)
    end
end)
    
-- begin roll
self._timerMove = UKismetSystemLibrary.K2_SetTimerDelegate({self, self.UpdateRollPos}, _Interval, true)

现在是用数组来储存消息,因为经常要排序,不能用以前实现的Queue,其实应该使用PriorityQueue,但是目前还没有时间实现。

主菜单关卡场景(2021.6.3)

主界面背景要用新场景而不是一张背景图,新的场景关卡提供过来之后,需要在World Settings里面设置Lobby用的GameMode,然后在BeginPlay时创建登录ui,就可以走之前的lobby逻辑了,现在要加一个摄像头切换,人物模型和相关的摄像机以前在另一个level里,现在将其放入这个新level作为sublevel,Controller中增加一个Event,每次需要切换摄像头时广播,然后分别在两个关卡蓝图中Bind它并执行逻辑。现在切摄像头时用LevelStreaming去Load和Unload关卡在手机上的包会闪退,暂时不知道原因。

旧关卡有灯光会影响到当前关卡,所以每次移动摄像头的时候动态的去load,不用了之后再unload,这衍生出很多问题,PlayerState之前存了一份模型actor的引用,就不用每次去找,但PlayerState并不知道关卡有没有被unload,有引用丢失的风险,所以干脆每次都找。

特殊提示(2021.6.8)

现在提示区分类型,接口加了一个枚举参数,并设置默认值,这样以前调用的地方都不用改,只用修改那些需要变成特殊提示的地方。显示提示的接口被定义为BlueprintImplementable,由lua脚本实现,根据枚举区分应该创建哪个ui,并设置在显示时传入颜色、icon路径等信息。显示的方式是队列(这段逻辑在专门显示提示的Container脚本中),把消息结构压入队列并标记正在处理,调用维护队列的方法,一个个将消息弹出显示,特殊提示grid的渐入渐出是动画,渐出动画完成后将自身RemoveFromParent,为了让外部知道自己动画播完了,定义一个EventDispatcher,在播完后广播,Container注册到其处理队列的方法,这样当grid播完动画后Container就会自动处理队列中的下一条消息。

有很多提示需要用到角色名字,而角色名存在PlayerState中,之前通过Player拿名字的接口也是通过PlayerState去拿,但客户端只会有自己的PlayerState,如果本地去取别人的PlayerState是取不到的,因此之前在服务器上执行的逻辑是没问题的,服务器有所有人的PlayerState,一旦客户端执行,就会出现队友名字拿不到的情况。为了以后能方便的拿到其他玩家的名字,在角色中增加一个名字的UPROPERTY字段并标记为Replicated,然后在GetLifetimeReplicatedProps中用宏DOREPLIFETIME注册,这样每当这个字段在服务器上有改动就会同步给所有存有该字段的客户端。

交互ui堆叠(2021.6.17)

交互ui是用ListView做的,如果要实现堆叠,要么弃用ListView,自己维护每个grid,要么在ListView的AddItem处做好处理,权衡之后选择了后者。

可堆叠和不可堆叠的grid要分开考虑,这个在交互的task中配置好,如果是不可堆叠的task,直接添加,如果是可堆叠的,则这个类型的task只在第一次添加item(目前没有堆叠上限),也就是每次添加之前先找一下有没有同类型的,如果找到了就只加数字。类似的,删除的逻辑同样分开考虑,要注意的是由于交互有检测玩家角度功能,会隔一段时间检测一下有没有交互物,因此已经添加或删除的task要做好标记,避免重复操作。

Guid在lua的赋值似乎有问题,好在引擎提供了将其转换为字符串的接口,直接保存字符串了。

增加渐入渐出动画表现

加入动画引入的问题比想象中多,主要是导致删除有一段延迟,渐出时item没有在第一时间Remove,如果这时再次触发AddItem,UE会报错。要解决这个问题有两个方案,一是第一时间移除item,但保留widget,在播完动画后再主动删除widget;二是在AddItem之前找一下是否存在一样的item,如果找到了就不加了。第一种方案试了之后不可行,ListView这个类中用一个TArray保存所有item,虽然lua能拿到它,但一删除item,widget也立马被删掉了,没有主动删除的机会。另一个问题是如果玩家操作过快,在渐出动画未播完时再次触发task,此时进入的task会被标记为已加入ListView,然后渐出播完触发Remove将item移除,虽然会再次检测添加,但此时就丢失了那些在item删除前已经触发的task,因为它们已经被标记过了。解决这个问题我选择使用task上的uid建一个DirtyPool,用第一个被加入的task(作为item被加入的task)的id作为索引,如果一个task要被删除那么就加进去,等最后item删除时一并清掉所有之前已经删除task的标志位,这样在重新检测时就不会出问题了。关于ListView的实现,后面还要找时间研究一下。

关闭大ui的逻辑在InteractionEffect里面,要等友哥回来问问能不能移到蓝图去。

辅助瞄准开关(2021.6.28)

这功能以前就有,现在只是需要加一个设置开关,沿用以前的设置逻辑,加3个字段保存,然后在实现辅助瞄准的地方加判断就行了。

看了一下实现,首先需要找到屏幕中可以被辅助瞄准的敌人,遍历NPC,把小于特定距离敌人的头、身、腿位置投射到屏幕坐标上,然后再看这几个坐标和屏幕中心(准心)的距离是否小于阈值,如果是就返回这个Actor结束循环。这里没有做离准心最近的敌人的筛选,可能是范围本身比较小,同时出现多个可以辅助瞄准的目标的情况很少出现。找到目标后,将摄像机转到对应的Actor身上就行了,

Batch语法

set /p VarName=用来接收prompt输入,并将输入赋给变量。

Bug合集

  • 需要ContextObject的时候把this传入报cannot convert to ‘UObject’的错,检查后发现该函数是const,因此this指针也是const,当然不能转了,改成GetWorld()就行了。
  • 创建新类时即使有GENERATED_UCLASS_BODY也需要定义Constructor
  • FString作为参数传入时记得加*
  • 服务器调用一个BlueprintImplementableEvent无法通知到lua,要单独写一个Client函数
  • 主干出现一个野指针问题,是我用来储存SaveGameObj的指针,没有用UPROPERTY,导致被gc
  • 犯了个错误,把服务器没有开发完全的功能合进了主干,导致若干问题出现
  • 有一条通知出现了两次,但客户端断点只进一次,原因是发送这条通知的地方是NetMulticast函数,而发送的接口中使用了Client函数,多播函数除了在每个客户端走之外,服务器也会走一次,此时调用了Client函数就会再通知一次客户端,就会通知两次,所以不要在多播函数中使用Client函数。
  • 之前做的交互UI在Remove合AddItem时的表现出现异常,发现检查角度RemoveItem时高概率出现回正后grid不再出现的情况,排查很久之后发现是因为ListView自己缓存了widget的实例,在RemoveItem之后再次AddItem它可能直接使用了之前的widget而不去重新generate,所以OnEntryGenerated不会走,而widget被我删除之前会播一段消失动画,播完后widget就保留了不可见的状态被重新添加进ListView,造成明明Add了但就是看不见的情况。解决方法是每次添加完后手动拿到widget并检查可见性,如果不可见则播放出现的动画

Comments

Content