Unity ECS实现RTS游戏中的游戏单位框选、集结和移动控制

今天想给大家分享的主题是如何实现RTS类型游戏中的游戏单位角色控制

本文中会介绍如何运用最新的ECS架构来实现游戏单位控制

效果演示

在这里插入图片描述

效果实现

选中多个游戏单位

public class UnitControlSystem : ComponentSystem 
{
    
    
		private float3 startPosition;
		protected override void OnUpdate() // OnUpdate与MonoBehaviour中的UPdate一样,游戏运行的每一帧都会执行OnUpdate
		{
    
    
				if(Input.GetMouseButtonDown(0)) // 鼠标左键按下时执行的内容
				{
    
    
						// Mouse Pressed
						StartPosition = UnilsClass.GetMouseWorldPosition(); // 记录鼠标按下的位置
				}
				if(Input.GetMouseButtonUP(0)) //鼠标左键弹起时执行的内容
				{
    
    
						// Mouse Released
						float3 endPosition = UnilsCalss.GetMouseWorldPosition(); // 记录鼠标弹起的位置
						
						float3 lowerLeftPosition = new float3(math.min(startPosition.x, endPosition.x),
								math.min(startPosition.y, endPosition.y),0); // 获取鼠标框选方框左下角的位置
						float3 upperRightPosition = new float3(math.max(startPosition.x, endPosition.x),
								math.max(startPosition.y, endPositon.y),0); // 获取鼠标框选方框右上角的位置
						
						Entities.ForEach((Entity entity, ref Translation translation) => {
    
    
								float3 entityPosition = translation.Value;
								if(entityPosition.x >= lowerLeftPosition.x &&
									 entityPosition.y >= lowerLeftPosition.y &&
									 entityPosition.x <= upperRightPosition.x &&
									 entityPosition.y <= upperRightPosition.y){
    
    
									 Debug.Log(entity);
								}
						}); // 遍历所有实体,判断它是否被框选选中
				}
		}
}
  • 上方代码实现的功能是获取被鼠标框选的游戏单位,如果需要源代码可以在文末添加爱丽丝老师的QQ或者微信号领取
  • 代码讲解
  • 获取鼠标框选方框的左下角和右上角
float3 lowerLeftPosition = new float3(math.min(startPosition.x, endPosition.x),
math.min(startPosition.y, endPosition.y),0); // 获取鼠标框选方框左下角的位置
float3 upperRightPosition = new float3(math.max(startPosition.x, endPosition.x),
math.max(startPosition.y, endPositon.y),0); // 获取鼠标框选方框右上角的位置
  • 鼠标在按下和弹起的过程中画出的方框一般存在两种情况
    • 鼠标的起始位置对应左下角,终止位置对应右上角
    • 鼠标按下时的起始位置是右上角,终止位置则是左下角
  • 在计算方框的起始位置为右上角,终止位置为左下角时不能直接用起始位置当方框的左下角,要把终止位置当做方框左下角的位置
  • 想要统一的获得左下角和右上角的位置需要写一些算法,如上方代码所示,这个算法很简单,就是比较起始位置和终止位置,取较小值作为左下角点,然后用两者的较大值作为右上角点
  • 查找被选中的游戏实体
Entities.ForEach((Entity entity, ref Translation translation) => {
    
    
								float3 entityPosition = translation.Value;
								if(entityPosition.x >= lowerLeftPosition.x &&
									 entityPosition.y >= lowerLeftPosition.y &&
									 entityPosition.x <= upperRightPosition.x &&
									 entityPosition.y <= upperRightPosition.y){
    
    
									 Debug.Log(entity);
								}
						});
  • ForEach方法的作用是遍历每一个游戏单位,后面的Lambda表达式的功能是判断游戏单位的位置坐标是否在鼠标框选范围内,并打印鼠标范围框选范围内的游戏单位

绘制选区

在这里插入图片描述

  • 在场景中创建一个空节点,起名为SelectionArea(选择区域),再创建一个空子节点,取名为Sprite(精灵节点)
    在这里插入图片描述
  • 为精灵节点添加精灵渲染器,并选择一张绿色的图片(把白色图片设置成绿色也可以)
  • 这里需要注意一下这张图片的大小
    在这里插入图片描述
  • 我们为这张图片设置了0.5的偏移值,这是什么意思呢?

在这里插入图片描述

  • 也就是说SelectionArea(选择区域)节点和Sprite(精灵)节点的位置关系变成了上图的样子,上图中红色坐标轴的原点就是SelectionArea(选择区域)节点的位置,蓝色坐标轴的原点则代表Sprite(精灵)节点的位置,这样偏移以后,将来拖拽、缩放选框时就会以红颜色的中心点为起点,会比较方便
  • 在代码中实现动态绘制选框
public struct UnitSelected : IComponentDate {
    
    
}

public class UnitControlSystem : ComponentSystem 
{
    
    
		private float3 startPosition;
		protected override void OnUpdate() // OnUpdate与MonoBehaviour中的UPdate一样,游戏运行的每一帧都会执行OnUpdate
		{
    
    
				if(Input.GetMouseButtonDown(0)) // 鼠标左键按下时执行的内容
				{
    
    
						// Mouse Pressed
						ECS_RTSControls.instance.selectionAreaTransform.gameObject.SetActive(true); // 鼠标按下时激活选区
						startPosition = UnilsClass.GetMouseWorldPosition(); // 记录鼠标按下的位置
						ECS_RTSControls.instance.selectionAreaTransform.position = startPosition; // 设置选取的位置为鼠标按下的位置
				}
				
				if(Input.GetMouseButton(0)) // 鼠标左键按下后,拖拽鼠标时要执行的内容
				{
    
    
						// Mouse Held Down
						float3 selectionAreaSize = (float3)UtilsClass.GetMouseWorldPosition() - startPositon; // 获取鼠标绘制出的选区大小
						ECS_RTSControls.instance.selectionAreaTransform.localScale = selectionAreaSize; // 设置选区大小
				}
				
				if(Input.GetMouseButtonUP(0)) //鼠标左键弹起时执行的内容
				{
    
    
						// Mouse Released
						ECS_RTSControls.instance.selectionAreaTransform.gameObject.SetActive(false); // 在鼠标抬起时隐藏选区
						float3 endPosition = UnilsCalss.GetMouseWorldPosition(); // 记录鼠标弹起的位置
						
						float3 lowerLeftPosition = new float3(math.min(startPosition.x, endPosition.x),
								math.min(startPosition.y, endPosition.y),0); // 获取鼠标框选方框左下角的位置
						float3 upperRightPosition = new float3(math.max(startPosition.x, endPosition.x),
								math.max(startPosition.y, endPositon.y),0); // 获取鼠标框选方框右上角的位置
						
						Entities.ForEach((Entity entity, ref Translation translation) => {
    
    
								float3 entityPosition = translation.Value;
								if(entityPosition.x >= lowerLeftPosition.x &&
									 entityPosition.y >= lowerLeftPosition.y &&
									 entityPosition.x <= upperRightPosition.x &&
									 entityPosition.y <= upperRightPosition.y){
    
    
									 PostUpdateCommands.AddComponent(entity, new UnitSelected()); // PostUpdateCommands.AddComponent是ECS里的API,
																																								// 它在这里的功能是为被选中的游戏对象添加UnitSelected组件
							  }
						}); // 遍历所有实体,判断它是否被框选选中
				}
		}
}
  • 注意
    • SelectionArea节点被添加到总控脚本ECS_RTSControls里了,所以在上方代码中是通过访问总控脚本的单例来获取SelectionArea节点
  • 此脚本是在之前的UnitControlSystem脚本上增加了一个类和一些代码
    • 这些代码会在鼠标按下时激活SelectionArea节点,并将SelectionArea节点的位置设置为鼠标按下的位置
    • 在鼠标拖拽过程中会不断获取鼠标当前位置,并用鼠标当前为减去鼠标初始位置,以得到当前鼠标框选的选区大小,然后赋值给selectionArea的localScale,这样就实现了selectionArea选区随着鼠标拖拽自动改变大小的效果
    • 最后在鼠标抬起时会将selectionArea的SetActive设置为false,隐藏鼠标选区,并为被选中的游戏对象添加UnitSelected结构体
  • 效果演示
    在这里插入图片描述

绘制角色脚下的圆圈

public class UnitSelectedRenderer : ComponentSysten
{
    
    
		protected override void OnUpdate()
		{
    
    
				Entities.WithAll<UnitSelected>().ForEach((ref Translation translation) => {
    
     // 通过for循环找到所有带有UnitSelected标记的游戏对象
						float3 position = translation.Value + new float3(0, -3f , +1); // 调低圆圈高度,使它出现在游戏对象脚下
						Graphics.DrawMesh(    
								ECS_RTSControls.instance.unitSelectedCircleMesh, // 通过ECS_RTSControls单例获取圆圈的网格模型
								translation.Value, // 指定圆圈位置为士兵所在的位置
								Quaternion.identity, // 指定旋转为不进行旋转
								ECS_RTSControls.instance.unitSelectedCircleMaterial, // 通过ECS_RTSControls的单例获取圆圈的材质
								0 // 指定要绘制的层
						); // Graphics.DrawMesh是Unity的底层接口,在这里用来绘制游戏角色脚底的圆圈
				});
		}
}
  • 注意
    • 用于绘制角色脚下圆圈的材质添加到了总控脚本ECS_RTSControls里,所以在上方代码中是通过访问总控脚本的单例来获取圆圈的材质的
    • 圆圈的网格模型则是在ECS_RTSControls调用Unity动画的创建网格方法动态创建的,所以也通过ECS_RTSControls的单例获取
  • unitSelectedCircleMaterial(角色选中材质)

在这里插入图片描述

如果需要项目源码或资源可以在文末通过添加爱丽丝老师的QQ获取

  • 效果演示

在这里插入图片描述

  • 问题

    • 问题1:无法取消选中
      • 上面的代码在选中了左边的角色后,点击空处或再次选中其他角色时并不会取消之前被选中角色的选中状态,为了要解决这个问题我们添加了几行代码,让UnitControlSystem脚本在鼠标弹起时遍历所有游戏对象,删除它们身上的UnitSelected组件
     if(Input.GetMouseButtonUP(0)) //鼠标左键弹起时执行的内容
     {
          
          
          	// Mouse Released
          	ECS_RTSControls.instance.selectionAreaTransform.gameObject.SetActive(false); // 在鼠标抬起时隐藏选区
          	float3 endPosition = UnilsCalss.GetMouseWorldPosition(); // 记录鼠标弹起的位置
          						
          	float3 lowerLeftPosition = new float3(math.min(startPosition.x, endPosition.x),
          			math.min(startPosition.y, endPosition.y),0); // 获取鼠标框选方框左下角的位置
          	float3 upperRightPosition = new float3(math.max(startPosition.x, endPosition.x),
          			math.max(startPosition.y, endPositon.y),0); // 获取鼠标框选方框右上角的位置
          						
          	Entities.WithAll<UnitSelected>().ForEach((Entity entity) => {
          
          
          			PostUpdateCommands.RemoveComponent<UnitSelected>(entity);
          	}); // 在鼠标弹起时遍历所有游戏对象,删除它们身上的UnitSelected组件
          						
          	Entities.ForEach((Entity entity, ref Translation translation) => {
          
          
          			float3 entityPosition = translation.Value;
          			if(entityPosition.x >= lowerLeftPosition.x &&
          				entityPosition.y >= lowerLeftPosition.y &&
          				entityPosition.x <= upperRightPosition.x &&
          				entityPosition.y <= upperRightPosition.y){
          
          
          				PostUpdateCommands.AddComponent(entity, new UnitSelected()); // PostUpdateCommands.AddComponent是ECS里的API,它在这里的功能是为被选中的游戏对象添加UnitSelected组件
          			}
          	}); // 遍历所有实体,判断它是否被框选选中
     }
          ```
          
    
  • 问题2:无法通过点击选中游戏对象

    • 上面代码实现的选中效果必须要把游戏对象整体框入才能选中,但在RTS游戏里,游戏玩家对于单个游戏对象是可以通过点击选中的,而且框选也很麻烦,那怎样才能让它可以点中选中一个对象呢?方法很简单
    • 就是扩大最小选区,选择区域在点击时自动扩大一圈,这样就能确保点击选中单个角色
         float selectionAreaMinSize = 10f; // 鼠标选区最小值
         float selectionAreaSize = math.distance(lowerLeftPosition, upperRightPosition); // 获取当前鼠标选区大小
         if(selectonAreaSize < selectionAreaMainSize) // 检测当前鼠标选区大小是否小于鼠标选区最小值
         {
          
          
         		lowerLeftPosition += new float(-1, -1, 0) * (selectionAreaMinSize - selectionAreaSize) * .5f; // 将鼠标选区左下角向下拉伸
         		upperRightPosition += new float(+1, +1, 0) * (selectionAreaMinSize - selectionAreaSize) * .5f; // 将鼠标选区右上角向上拉伸
         }
    
  • 注意:

    • 这些代码会在鼠标抬起时执行,它会检测当前鼠标选区大小是否小于鼠标选区最小值,如果小于则会根据鼠标选区最小值减去当前鼠标选区大小的值放大鼠标选区
  • 这样就保证了最小区域是足够大的,可以通过点击选中游戏单位

让游戏对象向指定的方向移动

if(Input.GetMouseButtonDown(1)) // 在鼠标右键按下时执行的内容
{
    
    
		Entities.WithAll<UnitSelected>().ForEach((Entity entity, ref MoveTo moveTo) =>{
    
     
				moveTo.position = UtilsClass.GetMouseWorldPosition(); // 设置移动目标点
				moveTo.move = true;	// 开始移动
		}); // 查找所有被选中的游戏对象,设置它们的移动目标点并开始移动
}
  • 注意:
    • 这段代码会在鼠标右键抬起时执行,它会为所有被选中的游戏对象设置移动目标点,并使游戏对象向目标点移动
    • MoveTo是一个移动脚本,MoveTo的position代表游戏角色移动的目标点,move代表是否开始移动,如果需要完整的ECS源码资源,可以在添加爱丽丝老师领取
  • 效果演示
    在这里插入图片描述

实现游戏单位按阵列移动

  • 上面实现的效果还有一个问题存在

在这里插入图片描述

  • 可以看到上面的两个角色变成一个了,因为如果他们的移动目标点是相同的,那么这两个角色在移动时就会重叠起来
if(Input.GetMouseButtonDown(1)) // 在鼠标右键按下时执行的内容
{
    
    
		float targetPosition = UtilsClass.GetMouseWorldPosition(); // 获取鼠标点击位置
		List<float3> movePositionList = new List<floa3>
		{
    
    
				targetPosition,
				tragetPosition + new float3(10,0,0),
				tragetPosition + new float3(20,0,0),
				tragetPosition + new float3(30,0,0),
		}; // 游戏单位的移动位置列表
		int positionIndex = 0;  // 移动位置列表的位置索引值
		Entities.WithAll<UnitSelected>().ForEach((Entity entity, ref MoveTo moveTo) =>{
    
     
				moveTo.position = movePositionList[positionIndex]; // 设置移动目标点
				positionIndex = (positionIndex + 1) % movePositionList.Count;
				moveTo.move = true;	// 开始移动
		}); // 查找所有被选中的游戏对象,设置它们的移动目标点并开始移动
}
  • 上面代码中的movePositionList是游戏单位的移动位置列表,当鼠标右键按下设置目标点时,这段代码会遍历所有被选中的游戏单位,并使用positionIndex(位置索引)取出移动位置列表里计算好的移动位置,让这些游戏单位的移动位置都不一样

  • 效果演示
    在这里插入图片描述

  • 可以看到被选中的游戏单位朝着同一个目标点移动,并且位置都各不相同,这是因为目标点在movePositionList经过处理后,产生四个位置不同的坐标点,这样赋值给游戏单位的就是位置不同的坐标点了

  • 这段代码的问题也很明显:当玩家选中四个以上的游戏单位进行移动时,仍然会产生重叠现象

    • 这是因为movePositionList里的元素只有四个,当这段代码遍历完所有元素时,就会从movePositionList的起始位置重新遍历,所以多出来的游戏单位位置会与其他游戏单位重叠
  • 解决这个问题最简单的方法就是增加movePositionList里的元素个数,让元素个数始终大于游戏单位个数,这个问题自然迎刃而解

  • 不过在一些游戏单位数量动辄就是几十、几百上下的RTS游戏中,这种方法就不够看了,需要用另一种方法

private List<float3> GetPositionListAround(float3 position, float distance, int positionCount)
{
    
    
		List<float3> positionList = new List<float3>(); // 创建一个float3列表
		for (int i = 0; i < positionCount; i++)
		{
    
    
				int angle = i * (360 / positionCount); // 用位置数量除以360以获得第i个位置在圆环上的角度
				float3 dir = ApplyRotationToVector(new float3(0,1,0), angle); 
				float3 position = startPosition + dir * distance; // 通过dir*distance获取长度为distance的向量,然后加上中心位置以得到向量的实际位置
				positionList.Add(position);
		}
		return positionList;
}

private float3 ApplyRotationToVector(float3 vec, float angle)
{
    
    
		return Quaternion.Euler(0,0,angle) * vec;
}
  • 上面的GetPositionListAround会返回一个位置列表,位置列表里的所有元素都会以该方法的position参数为圆心,distance为半径呈圆形排列(如下图),这些元素的数量就是positionCount

在这里插入图片描述

  • ApplyRotationToVector的作用则是通过传入的角度(angle)来构造旋转值,并使用这个旋转值旋转向量(vec),旋转到了angle所代表的角度,这个方法背后的数学原理,本文就不去细讲了,因为内容很多,如果想要学习这方面的知识,可以在文末添加爱丽丝老师了解
  • 对之前的代码进行修改,使用GetPositionListAround生产位置列表
if(Input.GetMouseButtonDown(1)) // 在鼠标右键按下时执行的内容
{
    
    
		float targetPosition = UtilsClass.GetMouseWorldPosition(); // 获取鼠标点击位置

		List<float3> movePositionList = GetPositionListAround(targetPosition, 10, 5); // 游戏单位的移动位置列表

		int positionIndex = 0;  // 移动位置列表的位置索引值
		Entities.WithAll<UnitSelected>().ForEach((Entity entity, ref MoveTo moveTo) =>{
    
     
				moveTo.position = movePositionList[positionIndex]; // 设置移动目标点
				positionIndex = (positionIndex + 1) % movePositionList.Count;
				moveTo.move = true;	// 开始移动
		}); // 查找所有被选中的游戏对象,设置它们的移动目标点并开始移动
}
  • 效果演示
    在这里插入图片描述

  • 由于在上面的代码中GetPositionListAround只指定了5个元素数量,所以重叠的现象依然存在,代码还需要继续改进

private List<float3> GetPositionListAround(float3 startPosition, float[] ringDistance, int[] ringPositionCount)
{
    
    
		List<float3> positionList = new List<float3>();
		positionList.Add(startPosition); 
		for (int ring = 0; ring < ringPositionCount.Length; ring++) // 使用for循环根据ringPositionCount元素个数生成圆环
		{
    
    
				List<float3> ringPositionList = GetPositionListAround(startPosition, ringDistance[ring], ringPositionCount[ring]); // 使用GetPositionListAround方法生成圆环和圆环上的位置点
				positionList.AddRange(ringPositionList);  // 将已经生成好的圆环放入positionList
		}
		return positionList;
}

private List<float3> GetPositionListAround(float3 position, float distance, int positionCount)
{
    
    
		List<float3> positionList = new List<float3>(); // 创建一个float3列表
		for (int i = 0; i < positionCount; i++)
		{
    
    
				int angle = i * (360 / positionCount); // 用位置数量除以360以获得第i个位置在圆环上的角度
				float3 dir = ApplyRotationToVector(new float3(0,1,0), angle); 
				float3 position = startPosition + dir * distance; // 通过dir*distance获取长度为distance的向量,然后加上中心位置以得到向量的实际位置
				positionList.Add(position);
		}
		return positionList;
}

private float3 ApplyRotationToVector(float3 vec, float angle)
{
    
    
		return Quaternion.Euler(0,0,angle) * vec;
}
  • 最上方的GetPositionListAround是之前写的GetPositionListAround的改进,可以让小兵的位置分布成几个圆环(如下图所示)

在这里插入图片描述

  • 新的GetPositionListAround方法的第一个参数startPosition仍然代表圆环中心点的位置,第二个参数ringDistance是半径数组,用于存放所有圆环的半径,第三个参数ringPositionCount代表每一个圆环上的位置点数量
if(Input.GetMouseButtonDown(1)) // 在鼠标右键按下时执行的内容
{
    
    
		float targetPosition = UtilsClass.GetMouseWorldPosition(); // 获取鼠标点击位置

		List<float3> movePositionList = GetPositionListAround(targetPosition, new float[] {
    
    10f, 20f, 30f}, new int[] {
    
    5, 10, 20}); // 游戏单位的移动位置列表

		int positionIndex = 0;  // 移动位置列表的位置索引值
		Entities.WithAll<UnitSelected>().ForEach((Entity entity, ref MoveTo moveTo) =>{
    
     
				moveTo.position = movePositionList[positionIndex]; // 设置移动目标点
				positionIndex = (positionIndex + 1) % movePositionList.Count;
				moveTo.move = true;	// 开始移动
		}); // 查找所有被选中的游戏对象,设置它们的移动目标点并开始移动
}
  • 效果演示

在这里插入图片描述

思考练习

  1. 实现不受位置点数量限制,为每一个游戏单位生成不与其他单位重叠的位置点(因为只要小兵数量超过上面代码中设置的位置点总数,还是会有游戏单位重叠现象)
  2. 兵种遇到障碍能否实现游戏单位躲避障碍行走到目标点(A*,ECS实现)

写在最后

  • 更多学习资源请加QQ:1517069595或WX:alice17173获取(企业级性能优化/热更新/Shader特效/服务器/商业项目实战/每周直播/一对一指导)
  • 点赞、关注、分享可免费获得配套学习资源
  • 点击观看完整视频

猜你喜欢

转载自blog.csdn.net/Raymond_King123/article/details/126471109