ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Unity RTS游戏框选系统实战:从UI搭建到视觉反馈完整实现

Unity RTS游戏框选系统实战:从UI搭建到视觉反馈完整实现 1. 项目概述从“点选”到“框选”的RTS核心交互进化在上一篇文章里我们聊透了RTS游戏中单位点选、编队和基础移动的逻辑。那就像是给一支军队任命了指挥官下达了“前进”的指令。但任何一个合格的指挥官都知道在瞬息万变的战场上只靠鼠标一个个去点选士兵是低效且致命的。真正的战场指挥需要的是“划一片区域这片区域里的所有单位听我号令”。这就是我们今天要深入骨髓去实现的**框选Box Selection**功能。框选不仅仅是UI界面上一个半透明的矩形框那么简单。它是连接玩家意图与游戏世界内数百个独立单位实体的桥梁是RTS游戏操作感的基石。一个响应迅速、逻辑清晰、视觉效果流畅的框选系统能极大提升游戏的策略性和操作爽快感。想象一下《星际争霸》中框选一队机枪兵散开阵型或是《帝国时代》里框选农民集体采集资源其背后都是一套精心设计的框选逻辑在支撑。本篇文章我们将完全聚焦于“生成选择框”这一核心环节。我们将从零开始构建一个在3D游戏世界中根据鼠标拖拽轨迹动态生成并渲染一个贴合视角、边界清晰的选择框。这不仅是UI的绘制更涉及屏幕空间到世界空间的坐标转换、几何体的动态构建与渲染以及为后续的单位选择逻辑打下坚实的数据基础。无论你是Unity的初学者还是想深化对RTS核心机制理解的开发者跟随这篇实战解析你都能获得一套可直接复用到自己项目中的、工业级的框选解决方案。2. 框选系统的核心设计思路与架构在动手写代码之前我们必须想清楚框选系统到底要做什么以及如何优雅地实现它。一个健壮的框选系统可以拆解为三个核心阶段输入捕获、几何生成、逻辑判定。本篇我们解决前两个为第三阶段铺平道路。2.1 核心流程拆解从鼠标按下到屏幕矩形框选的本质是玩家用鼠标在屏幕上定义了一个二维的矩形区域。我们的系统需要将这个二维区域转化为游戏世界中的一个“选择体”并找出其中的单位。流程如下按下鼠标记录鼠标按下的初始屏幕坐标点startPos。拖动鼠标实时获取当前鼠标的屏幕坐标点currentPos。生成矩形根据startPos和currentPos计算出一个屏幕空间的矩形。投影到世界将这个屏幕矩形投影到游戏世界的近裁切面和远裁切面形成一个3D空间中的“视锥体切片”。更简化且常用的方法是我们只关心这个矩形在游戏世界水平面如y0的地面或单位所在高度上的投影区域。检测单位下篇重点使用物理系统如Physics.OverlapBox或Physics.OverlapArea检测投影区域内的所有单位。我们的首要任务就是清晰、可视化地将第1-3步的“屏幕矩形”呈现给玩家这就是“生成选择框”。2.2 方案选型如何绘制这个动态框在Unity中动态绘制一个2D UI矩形框主要有以下几种主流方案各有优劣方案一使用Unity UI (uGUI) 的Image组件这是最直观的方法。创建一个全屏的Canvas下面放一个Image将其Image Type设置为Sliced使用九宫格精灵以避免拉伸模糊然后通过代码控制其rectTransform的offsetMin和offsetMax或者anchoredPosition和sizeDelta来改变位置和大小。优点简单易上手与UI系统集成度高易于处理屏幕自适应。缺点Canvas的渲染可能带来额外的Overdraw框选框被限制在UI层如果游戏有复杂的UI层级可能需要额外处理性能在极端情况下如需要大量UI元素时可能不如直接渲染。方案二使用GL类或Graphics.DrawMesh进行即时模式渲染这是更底层、更灵活的图形API。GL类可以用于在OnPostRender或特定摄像机的渲染事件中绘制简单的几何图形如线段。Graphics.DrawMesh则允许你动态绘制一个网格。优点性能极高完全可控不依赖UI系统绘制出的框可以存在于世界空间或屏幕空间。缺点代码相对复杂需要自己处理网格生成、材质和渲染队列对初学者不友好。方案三使用一个简单的3D Quad平面与动态材质创建一个非常简单的平面网格附着在一个GameObject上。通过脚本动态更新这个网格的四个顶点坐标使其构成一个矩形并应用一个带有透明和边框效果的Shader材质。优点概念清晰直接操作世界空间顶点性能较好框选框可以轻松融入3D世界如带有透视效果。缺点需要处理屏幕坐标到世界坐标的转换以确定矩形的四个角在世界中的位置逻辑上比UI方案稍绕。我们的选择与理由对于一款典型的RTS游戏框选框是一个需要频繁更新、且要求响应速度极快的视觉反馈。UI方案在开发效率、与现有UI系统的兼容性以及屏幕坐标处理的简便性上具有明显优势。RTS游戏的界面通常复杂框选框作为界面交互的一部分使用UI系统管理在架构上更清晰。只要对Canvas进行合理规划例如使用一个专用的、渲染模式为Screen Space - Overlay的Canvas并控制其渲染顺序性能完全不是问题。因此本案例我们将采用方案一uGUI作为实现基础。我们会深入细节不仅实现功能还会优化体验比如解决框选反向拖动时的矩形显示问题以及实现一个美观的半透明填充边框效果。3. 构建动态选择框从UI搭建到代码驱动理论清晰了现在开始实战。我们一步步构建出那个跟随鼠标拖拽而变化的半透明选择框。3.1 UI层级搭建与组件配置首先在Unity编辑器中完成UI部分的搭建。创建一个新的Canvas。将Render Mode设置为Screen Space - Overlay这样它就会始终绘制在最上层。为了性能可以勾选Additional Shader Channels下的TexCoord1,Normal,Tangent虽然本例不一定需要但好习惯。在Canvas下创建一个空的GameObject命名为SelectionBox。它将作为我们框选框的根节点。在SelectionBox下创建一个Image组件命名为BoxVisual。这就是我们框选框的视觉表现。配置BoxVisual的Image组件Source Image: 这里需要一个“九宫格Sliced”精灵来确保边框在任何缩放下都不模糊。你可以使用Unity内置的白色精灵WhiteSprite或者自己制作一个简单的边框贴图。关键步骤在Project面板选中你的精灵在Inspector中设置Sprite Mode为Multiple然后点击Sprite Editor。在编辑器中将绿色的边框线向内拉定义出九宫格的内部区域例如每边设置10像素。这样当Image拉伸时只有中间部分被拉伸边框保持原样。Image Type: 设置为Sliced。这是实现不模糊边框的核心。Color: 设置为一个半透明的颜色比如RGBA(0, 120, 255, 0.2)。A值Alpha控制透明度。调整BoxVisual的RectTransformAnchor和Pivot都设置为(0, 0)即左下角。这会让我们的坐标计算变得非常直观——矩形的起点就是左下角尺寸就是宽和高。初始时将其Width和Height都设为0PosX和PosY设为0让它处于不可见状态。注意使用Sliced模式时如果矩形尺寸小于你定义的九宫格边框总和它可能无法正确显示。在代码中我们可以通过判断尺寸来避免这个问题或者使用Tiled模式作为备选。3.2 核心脚本SelectionBoxController接下来我们创建核心脚本SelectionBoxController.cs并将其挂载到SelectionBox游戏对象上。using UnityEngine; using UnityEngine.UI; public class SelectionBoxController : MonoBehaviour { [Header(UI References)] [SerializeField] private RectTransform boxVisualRectTransform; // 拖拽赋值BoxVisual [SerializeField] private Image boxVisualImage; // 拖拽赋值BoxVisual上的Image组件 [Header(Visual Settings)] [SerializeField] private Color boxColor new Color(0f, 0.47f, 1f, 0.2f); // 默认半透明蓝色 // 框选状态 private bool isSelecting false; private Vector2 startScreenPosition; private Vector2 currentScreenPosition; // 屏幕尺寸缓存用于坐标转换 private Vector2 screenSize; void Start() { InitializeBox(); screenSize new Vector2(Screen.width, Screen.height); } void Update() { HandleSelectionInput(); UpdateBoxVisual(); } /// summary /// 初始化框选框使其隐藏 /// /summary private void InitializeBox() { if (boxVisualImage ! null) { boxVisualImage.color boxColor; boxVisualImage.enabled false; // 初始隐藏 } if (boxVisualRectTransform ! null) { boxVisualRectTransform.sizeDelta Vector2.zero; } } /// summary /// 处理鼠标输入确定框选开始、进行和结束 /// /summary private void HandleSelectionInput() { // 鼠标左键按下开始框选 if (Input.GetMouseButtonDown(0)) { StartSelection(); } // 鼠标左键抬起结束框选选择逻辑在下一篇 else if (Input.GetMouseButtonUp(0)) { EndSelection(); } // 鼠标左键按住且处于框选状态更新当前鼠标位置 if (isSelecting) { currentScreenPosition Input.mousePosition; // 限制当前坐标在屏幕范围内可选但更健壮 currentScreenPosition.x Mathf.Clamp(currentScreenPosition.x, 0, screenSize.x); currentScreenPosition.y Mathf.Clamp(currentScreenPosition.y, 0, screenSize.y); } } /// summary /// 开始框选记录起始点 /// /summary private void StartSelection() { // 这里可以添加判断例如当点击在UI上时不开始框选 // if (EventSystem.current.IsPointerOverGameObject()) return; isSelecting true; startScreenPosition Input.mousePosition; currentScreenPosition startScreenPosition; // 初始时当前位置就是起始点 if (boxVisualImage ! null) { boxVisualImage.enabled true; // 显示框选框 } } /// summary /// 结束框选隐藏框选框 /// /summary private void EndSelection() { if (!isSelecting) return; isSelecting false; if (boxVisualImage ! null) { boxVisualImage.enabled false; // 隐藏框选框 } if (boxVisualRectTransform ! null) { boxVisualRectTransform.sizeDelta Vector2.zero; // 重置大小 } // 触发单位选择事件下一篇实现 // ProcessSelection(); } /// summary /// 根据起始点和当前点更新框选框的视觉表现位置和大小 /// /summary private void UpdateBoxVisual() { if (!isSelecting || boxVisualRectTransform null) return; // 计算矩形左下角原点和尺寸 // RectTransform的锚点在左下角(0,0)所以原点就是矩形左下角的屏幕坐标。 // 我们需要确保无论鼠标从哪个方向拖动计算出的矩形原点都是左下角尺寸都是正数。 float left Mathf.Min(startScreenPosition.x, currentScreenPosition.x); float right Mathf.Max(startScreenPosition.x, currentScreenPosition.x); float bottom Mathf.Min(startScreenPosition.y, currentScreenPosition.y); float top Mathf.Max(startScreenPosition.y, currentScreenPosition.y); // 矩形的原点左下角和宽高 Vector2 boxOrigin new Vector2(left, bottom); Vector2 boxSize new Vector2(right - left, top - bottom); // 设置RectTransform的位置和大小 // 注意屏幕坐标原点在左下角RectTransform在Overlay Canvas下的原点也在左下角所以可以直接赋值。 boxVisualRectTransform.anchoredPosition boxOrigin; boxVisualRectTransform.sizeDelta boxSize; // 可选如果矩形太小可以隐藏或显示一个最小视觉反馈 if (boxSize.magnitude 10f) // 例如小于10像素认为点击 { boxVisualImage.enabled false; } else { boxVisualImage.enabled true; } } // 提供一个公共属性供其他脚本如下一篇的选择逻辑获取当前的屏幕空间矩形 public Rect? CurrentScreenRect { get { if (!isSelecting) return null; float left Mathf.Min(startScreenPosition.x, currentScreenPosition.x); float right Mathf.Max(startScreenPosition.x, currentScreenPosition.x); float bottom Mathf.Min(startScreenPosition.y, currentScreenPosition.y); float top Mathf.Max(startScreenPosition.y, currentScreenPosition.y); return new Rect(left, bottom, right - left, top - bottom); } } }3.3 代码逻辑深度解析这段代码是框选视觉反馈的核心有几个关键点需要深入理解坐标系统统一我们全程使用屏幕坐标Input.mousePosition。在Screen Space - Overlay模式的Canvas下RectTransform的anchoredPosition和sizeDelta也直接对应屏幕像素坐标这使得转换非常简单直接。anchoredPosition是UI元素**枢轴点Pivot相对于其锚点Anchor**的偏移量。因为我们把锚点和枢轴都设为了(0,0)即左下角所以anchoredPosition的(x, y)就是矩形左下角距离屏幕左下角的像素距离。矩形标准化计算UpdateBoxVisual方法中的Mathf.Min和Mathf.Max是精髓所在。它确保了无论玩家是从左上往右下拖还是从右下往左上拖亦或是任意方向拖我们计算出的boxOrigin始终是矩形的左下角boxSize的宽高始终是正数。这是正确显示矩形框的前提。状态管理isSelecting布尔变量清晰地划分了框选的“开始-进行-结束”三个状态。在EndSelection中我们不仅隐藏了UI还将大小重置为零。这是一个好习惯可以避免在极快点击时上一帧的矩形残影被短暂看到。公共接口我们暴露了一个CurrentScreenRect属性返回一个可空的Rect。这为下一篇要实现的单位选择逻辑提供了完美的数据接口。选择逻辑只需要查询这个属性如果非空就表示当前存在一个有效的屏幕选择矩形可以将其用于物理检测。4. 高级优化与视觉增强基础功能已经完成但一个精致的RTS游戏框选框的体验也需要打磨。下面介绍几个提升体验的进阶技巧。4.1 解决UI遮挡区分框选与点击UI在游戏中玩家可能会点击按钮、滑动滚轮。我们不希望在这些UI元素上操作时触发框选。Unity的EventSystem提供了检测鼠标是否在UI上的方法。确保场景中有EventSystem通常创建Canvas时会自动生成。在StartSelection方法开头添加检查private void StartSelection() { // 检查鼠标是否在UI元素上如果是则忽略框选 if (EventSystem.current ! null EventSystem.current.IsPointerOverGameObject()) { return; } // ... 原有逻辑 }注意IsPointerOverGameObject()默认对触摸和鼠标左键有效。如果你使用其他鼠标按键框选可能需要传递指针ID。对于鼠标通常使用-1作为默认指针ID或者使用EventSystem.current.currentInputModule的相关方法。4.2 创建更美观的框选框材质默认的UI Image颜色填充可能显得有些平淡。我们可以创建一个简单的自定义Shader或使用材质来实现“只有边框内部半透明填充”的效果这更符合传统RTS的审美。方法A使用Sprite并配合Image的Image Type为Sliced这是最简单的方法如前所述制作一个边框清晰的九宫格精灵图。例如一个纯白色边框、中间透明的PNG图片。在Sprite Editor中设置好九宫格边界后应用到Image上并设置颜色。调整边框粗细可以通过修改九宫格的边界大小来实现。方法B使用简单的Shader GraphURP/HDRP或编写Shader如果你想实现动态颜色、虚线边框等更复杂的效果Shader是更好的选择。创建一个新的Unlit Shader Graph以URP为例。主要逻辑是根据UV坐标判断片段是否靠近边缘例如UV的x或y小于0.05或大于0.95如果是则输出边框颜色否则输出一个半透明的填充颜色。将这个Shader保存并创建一个对应的Material。将BoxVisual的Image组件的Material属性指定为这个新Material。这里提供一个简易的Shader代码示例适用于Built-in Render PipelineShader \UI/SelectionBox\ { Properties { _FillColor (\Fill Color\, Color) (0, 0.47, 1, 0.2) _BorderColor (\Border Color\, Color) (0, 0.8, 1, 0.8) _BorderWidth (\Border Width\, Range(0, 0.5)) 0.02 } SubShader { Tags { \Queue\\Transparent\ \RenderType\\Transparent\ \IgnoreProjector\\True\ } Blend SrcAlpha OneMinusSrcAlpha ZWrite Off Cull Off Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include \UnityCG.cginc\ struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float2 uv : TEXCOORD0; float4 vertex : SV_POSITION; }; fixed4 _FillColor; fixed4 _BorderColor; float _BorderWidth; v2f vert (appdata v) { v2f o; o.vertex UnityObjectToClipPos(v.vertex); o.uv v.uv; return o; } fixed4 frag (v2f i) : SV_Target { // 判断当前片段是否在边框区域内 float2 border smoothstep(0, _BorderWidth, i.uv); border * smoothstep(1, 1 - _BorderWidth, i.uv); // 如果uv的x或y靠近0或1则border因子会变小 float borderFactor (1 - border.x * border.y); // 混合填充色和边框色 fixed4 col lerp(_FillColor, _BorderColor, borderFactor); // 另一种更清晰的边框计算 // if (i.uv.x _BorderWidth || i.uv.x 1 - _BorderWidth || i.uv.y _BorderWidth || i.uv.y 1 - _BorderWidth) // return _BorderColor; // else // return _FillColor; return col; } ENDCG } } }将这个Shader赋给一个材质再将该材质赋给BoxVisual的Image组件你就能得到一个可自定义填充色、边框色和边框宽度的漂亮选择框。4.3 性能考量与对象池思想虽然单个UI Image的性能消耗微乎其微但在大型项目中养成好习惯很重要。我们的SelectionBoxController在每次拖拽时都会频繁调用UpdateBoxVisual并修改RectTransform的属性。这是UI系统的标准操作性能足够好。但我们可以引入“对象池”的思想来应对未来可能更复杂的需求比如多选框选魔兽争霸3中英雄物品栏的框选样式或框选框的动画效果如框选完成时的闪烁。我们可以在初始化时创建好框选框的视觉对象而不是动态实例化/销毁。通过Enable/Disable来控制显示而不是反复创建新的UI元素。我们的当前实现已经符合这一思想在Start中初始化在StartSelection和EndSelection中启用/禁用。5. 常见问题排查与调试技巧即使按照步骤操作你也可能会遇到一些意想不到的问题。这里记录了几个常见的坑和解决方法。5.1 框选框不显示或显示异常检查Canvas渲染模式确保Canvas的Render Mode是Screen Space - Overlay。如果是World Space你需要将屏幕坐标转换为Canvas下的本地坐标复杂得多。检查RectTransform锚点确认BoxVisual的锚点Anchors和枢轴Pivot都设置为(0,0)。如果锚点拉伸anchoredPosition和sizeDelta的行为会完全不同。检查Image组件和材质确保BoxVisual上的Image组件被启用且Source Image或Material有效。如果是自定义Shader检查Shader是否编译成功属性是否正确赋值。调试坐标在UpdateBoxVisual方法中添加Debug.Log打印出boxOrigin和boxSize的值。确保它们在你拖拽时是合理的正数例如原点在屏幕内尺寸大于0。如果出现负数检查Mathf.Min/Max计算逻辑。5.2 框选框与鼠标位置不同步或抖动输入帧率与渲染帧率Input.mousePosition在Update中获取而UI更新可能在Update之后。Unity的UI系统通常能很好地处理但如果感觉不同步可以尝试在LateUpdate中调用UpdateBoxVisual。坐标转换错误牢记我们使用的是屏幕像素坐标。如果你不小心将鼠标坐标除以了屏幕宽高进行了归一化得到0-1的范围那么赋值给anchoredPosition时就会错得离谱。Canvas Scaler的影响如果你的Canvas使用了Canvas Scaler进行分辨率自适应这是推荐做法anchoredPosition和sizeDelta仍然是基于参考分辨率的像素值而不是屏幕实际像素。这通常不是问题因为鼠标坐标Input.mousePosition是屏幕实际像素而RectTransform的屏幕坐标转换由Unity自动处理。但为了绝对精确你可以使用RectTransformUtility.ScreenPointToLocalPointInRectangle将屏幕坐标转换到目标RectTransform的本地坐标空间。对于简单的Overlay Canvas我们当前的方法在大多数情况下工作良好。5.3 框选时误触发其他操作如摄像机移动这是RTS游戏常见的输入冲突。通常的解决方案是输入优先级分层。在StartSelection中严格判定如前所述通过EventSystem.current.IsPointerOverGameObject()排除UI点击。使用修饰键例如约定只有按住Ctrl或Shift键时的拖拽才是框选否则是摄像机拖拽。这需要在StartSelection中检查Input.GetKey(KeyCode.LeftControl)。区域判定例如只在屏幕中间的游戏视图区域非UI边缘允许框选。状态机实现一个更全局的输入状态机。例如定义一个GameInputMode枚举包含None,CameraPan,BoxSelecting,UnitCommand等状态。任何输入处理前先检查当前模式。框选逻辑只在BoxSelecting模式下生效而进入该模式可能需要特定的鼠标动作如左键拖拽且未点击UI。5.4 在Editor中测试的注意事项在Unity Editor中运行游戏时如果你的Game视图窗口不是全屏鼠标坐标的原点((0,0))是Game视图的左下角而不是整个显示器的左下角。这是正常行为。Input.mousePosition返回的是相对于当前活动窗口Game视图的像素坐标。当你发布成独立应用后坐标就是相对于整个游戏窗口的。至此一个响应灵敏、视觉清晰、代码健壮的RTS游戏动态选择框已经完整实现。它静静地躺在屏幕之上等待着玩家的拖拽指令。在下一篇中我们将为这个视觉框注入灵魂将屏幕上的这个矩形区域转化为游戏世界里对单位实体的精确选择。我们将深入探讨如何将屏幕矩形转换为世界空间的检测区域如何使用物理系统高效地进行单位筛选并处理多单位、分层选择等复杂情况。
返回列表