跳至主要內容

M.背包系统01|永劫背包复刻

Mr.Si大约 12 分钟u++

导言

头像
恭喜你,发现了本站秘宝。此乃鄙人入魔篇。

头像
我想实现一个类似永劫那样的背包系统!
头像
安排!

《永劫无间》是一款以斗罗大陆为背景的动作角色扮演游戏,拥有多种武器、技能、时装和战斗模式,让玩家体验不同的英雄故事和风格。

头像
永劫无间基于U3D引擎,并不妨碍我们思考问题,咱们先进入游戏体验一波。

初体验

进入下个环节前,需了解一个概念:
1.玩家(Player)指的是用户,视野(Camera|View)指的是用户操作的相机视角,后面说玩家二者默认一起考虑。
2.而角色(Pawn|Character)指的是玩家操作的角色,或者模型本身。

头像
按Tab打开背包可以看到以下界面:
头像
两边有暗角,应该是防止一些过亮环境看不清背包内容。并且我发现平时这个附近是看不到的。
GIF
头像
上图可知,打开背包前只会显示具体的物品信息UI,打开背包后会被加载到附近列表中。

机制猜测

头像
我觉得物体显示的图层和背包的图层应该是根据索引切换置顶的。

真实情况咱们不得而知,也没必要得知。方法也有很多,咱们只是抛砖引玉,先让自己进入状态。

头像
UE中对应的组件——WidgetSwitcher。

WidgetSwitcher

基本使用流程,后续不会使用此方法。

  1. 创建一个UWidgetSwitcher
  1. 加入一些要切换显示的数据比如图片
  1. 用节点切换到对应的图层Index

道具信息

道具UI

GIF
头像
上图中可以得知,玩家无论面向物体还是背向物体,进入某个范围都能显示对应的物品UI。
头像
也可能是角色朝向方向发射射线和物体相交检测。无论如何都要先获取物体的指针。货郎的购买也是这样,固定位置显示一个购买按钮。
GIF

机制思考

头像
至少现在咱能确认一个信息,即UI始终固定在屏幕某个位置,避免绑定在物体上过度绘制。
头像
经验和直觉告诉我们还得配合委托,广播刷新显示对应数据。
GIF
头像
说干就干,在UE中准备对应的UI

附近列表

显示数量

头像
数量大概12个
头像
安排,并且把暗角也安排上,注意这里咱用的是ListView
头像
对应的附近列表子项ItemUI,咱就不先考虑耐久了。

显示细节

头像
鼠标Hover图标会出现更详细的道具详细。
头像
这个咱们也先不考虑。
头像
我观察到道具和武器好像显示不太一样!武器中多了一个替换!
头像
这个简单,根据当前类型显示|隐藏这个选项就行了。

至于为什么有这个替换,这里卖个关子,可以先阅读下去。

添加移除

头像
随着角色推进,物体被依次推入列表。反只则反
GIF
头像

至此,脑海中这个附近列表的入栈有了个基本构想

构想①

头像
道具上绑定碰撞体,碰撞开始推入背包栈,碰撞结束弹出背包栈。

构想②

头像
角色身上绑定碰撞体

碰撞范围

头像
考虑性能问题,构想①的思路好像比较靠谱。
头像
可以先用构想①尝试一波,不过很快你会发现问题的。
头像
注意一个细节——测试中准星并没有对准物体。

小实验1

头像
纸上得来终觉浅,咱还是开始动手做个实验Demo吧。

实验版本,为了快速实现最小Demo,后续咱们再回头来考虑优化问题。

  1. 一个背包组件/子系统 + 一个背包交互的接口 +物体本身

测试阶段用的是组件,但我推荐你用子系统,因为还要考虑大厅显示UI数据问题。

  1. 背包内需要一个临时数组,保存附近列表的物体
	UPROPERTY(BlueprintReadWrite, EditAnywhere, meta=(DisplayName="Items"))
	TArray<TObjectPtr<AActor>> CurrentItems;
  1. 配套的UI

①. 道具显示页面

USTRUCT(BlueprintType)
struct  FBaseStruct 
{
	GENERATED_BODY()
public:
	
	UPROPERTY(BlueprintReadWrite, EditAnywhere, meta=(DisplayName="Name"))
	FText Name;

	UPROPERTY(BlueprintReadWrite, EditAnywhere, meta=(DisplayName="Description"))
	FText Description;
	
	UPROPERTY(BlueprintReadWrite, EditAnywhere, meta=(DisplayName="Icon", MakeStructureDefaultValue="None"))
	TObjectPtr<UTexture2D> Icon;
};

USTRUCT(BlueprintType)
struct FPickUpInfoStruct
{
	GENERATED_BODY()
	
	//基本信息|名称_描述_Icon
	UPROPERTY(BlueprintReadWrite, EditAnywhere, meta=(DisplayName="PickUpItemUIInfo"))
	FBaseStruct ItemInfo;

	//品质
	UPROPERTY(BlueprintReadWrite, EditAnywhere, meta=(DisplayName="PickUpItemQuantity"))
	int32 Quantity;

	//按键信息
	UPROPERTY(BlueprintReadWrite, EditAnywhere, meta=(DisplayName="PickUpKey"))
	TMap<FName,FName> Keys;
	
};

②. 背包骨架

头像
咱暂时不考虑魂玉,可以把这栏修改成武器背包,然后把原本武器背包替换成材料背包。
头像

如果你会GAS,也可以加上配套的GE,关于GAS传送门

	UPROPERTY(BlueprintReadWrite, EditAnywhere, meta=(DisplayName="GameplayEffect"))
	TArray<TSubclassOf<UGameplayEffect>> Effect;
  1. 一个ItemBaseActor
头像
不管三七二十一,先加个碰撞体

对应蓝图

GIF
头像

咱们平时看到的附近列表,都会临时缓存在背包的附近列表数组中。List的子项都是一个物体本身的指针。

头像

通过碰撞体和接口加入物体或者移除物体,这没毛病。但永劫里还有一个变数——物品信息UI

堆叠问题

头像
如果道具摆放的非常分散,这个思路不会出错。
GIF
头像
一旦大量道具堆叠,就会出大毛病!
头像
一旦大量道具堆叠,则只会显示最后加入的物体详细。
头像
那么永劫中是怎么解决这个问题的?
头像
附近物体列表有物体的情况下(注意看这里的道具顺序),关闭背包后连续按E会依次从第一个切换到最后一个。
头像
可是按E不是拾取嘛?为什么会切换?

拾取细节

头像
这里有个前置条件——背包中道具已满,并且无法替换更高品质的道具。
头像
这也是他的武器为什么要设计一个单独替换按键的原因。
头像
那我想捡东西岂不是每次都要点满才行?这也太不方便了!
头像
永劫的设计师当然考虑到了这个问题,当你的准星朝向不同的物体,会立即显示对应的物体。
头像
你这准星也没完全对齐啊?
头像
因为检测的并不是道具本身的碰撞,而是道具外的碰撞体。
头像
我不理解,你已经在碰撞体内部了,发射射线不对准道具怎么检测?
头像
这个倒好解决,可以用两碰撞体,一个负责检测Pawn,一个负责接收射线。
头像
但是,我要说但是。随之而来的BUG就出现了,先画个图。
头像
你只碰到了第一个Actor,附近列表中也只显示一个,但你的射线却能碰到更远的物体,导致显示BUG
头像
如果我手动把这个物体加到数组中呢?
头像
手动加入意味着你不去触发道具的EndOver函数会永远留在附近列表中。
头像
也就是说,射线距离应该和道具检测距离匹配是吗?这么看来只能用构思②的思路了!
GIF
头像
从上可知,构思①和构思②都存在这个问题,只能想办法过滤掉不在背包的内容,即对射线命中的物体进行一次是否在列表中查询操作。

背包组件对应蓝图,后面要进行C++重构,而且其实有更好的办法。

缺陷

头像
而且这样做有个致命缺陷,非常依赖碰撞体,即视角稍微抬高就无法命中
GIF
头像
把物体碰撞体改长点?

2.0

头像
静下来心来思考,我们主要目的是什么?是不是计算物体的位置与玩家视口方向的夹角来确定最近的物体?
头像
好像是这个一个回事!
GIF
头像
而且就功能性来看,这个完全可以封装到蓝图函数库里。并且改进版完全摆脱了碰撞体的依赖。(即所有球体碰撞都可以移除了,也不需要发射射线了)
AActor* UExorcistFunctionLibrary::FindNearestActorInDirection(APlayerController* PlayerController,
	const TArray<AActor*>& Actors)
{
	if (!PlayerController || Actors.Num() == 0)
	{
		// 处理找不到玩家控制器或者没有物体的情况
		return nullptr;
	}

	// 获取玩家视口的位置和方向
	FVector PlayerViewLocation;
	FRotator PlayerViewRotation;
	PlayerController->GetPlayerViewPoint(PlayerViewLocation, PlayerViewRotation);

	float MinAngle = MAX_FLT;
	AActor* NearestActor = nullptr;

	// 遍历物体数组
	for (AActor* Actor : Actors)
	{
		if (Actor)
		{
			// 获取物体位置
			FVector ActorLocation = Actor->GetActorLocation();

			// 计算物体与玩家视口方向的向量
			FVector ToActor = ActorLocation - PlayerViewLocation;
			ToActor.Normalize();

			// 计算玩家视口方向的向量
			FVector ViewDirection = PlayerViewRotation.Vector();

			// 计算物体与玩家视口方向的夹角
			float Angle = FMath::RadiansToDegrees(FMath::Acos(FVector::DotProduct(ToActor, ViewDirection)));

			// 更新最小夹角和对应的物体
			if (Angle < MinAngle)
			{
				MinAngle = Angle;
				NearestActor = Actor;
			}
		}
	}

	return NearestActor;
}

背包部分

永劫里面,道具还分可暂存道具比如钩锁、武备、药品、护甲粉和直接使用道具,比如果实这种。为此我们需要设计一个合理的使用 机制,这也是我们接下来讨论背包部分的重点。

使用时机

头像
物品被分成了道具背包、魂玉背包、武器背包。其实吧,加个配置字段就行了,生成的物体可以直接用还是缓存到附近列表(后续称做临时背包)、背包中。
头像

但你发现没,这些直接使用的道具往往不是我们口中的掉落道具,他们更像是功能性道具,比如果实、萤火虫。

头像
也就是说他们可能被另外一个Actor携带和配置,永劫里小怪(AI Pawn)死后也会有掉落道具的行为。

小实验2

头像
我们整理一下物体从看到到使用的简单过程吧。
头像
简单分析一下:
  1. 信息交互的媒介为碰撞体、组件、角色Pawn
  2. 信息内容是配置好的道具内容,本质上其实是数据的增删改查。
头像
回头看一下我们的小实验1内容,我们仅仅考虑了交互媒介。却没考虑信息内容的存取。
头像
你的组件不就在负责数据存取嘛?
头像
那么请问,这个数据是哪里来的?
头像
道具啊!
头像
道具上的数据是哪里来的?
头像
场景生成的呗
头像
场景凭空生成嘛?
头像
。。。。。
头像
当然不是凭空生成,而是放在数据库中根据规则生成。在网络篇已经充分讨论了客户端不可信。 即所有关键操作必须由服务器完成。但我们得放权给某些类去执行这些操作。

背包边界

头像
另外,我注意到一个细节,打开货郎和打开背包似乎是同个页面的不同接口调用
头像

如果说附近列表属于背包的一部分,那么可以认为打开货郎这个操作就是打开背包,并且Push进货郎的UI。

货郎

移动模式

头像
无论是打开背包还是打开货郎,角色除了不能动视角以外仍然可以移动。
头像

这个我们在Lyra-Inputmode的封装 已经讨论过了,游戏的3种状态。

头像
离开货郎后背包就会关闭,重新进入后只会显示购买UI.
GIF
头像
这个也很好理解,碰撞体的EndOver事件触发后,广播了一条关闭背包的委托。重新进入触发StartOver,则广播一条打开背包的委托,并且加载 货郎UI。