☰
Unity UI点击检测:准确获取被点中的UI GameObject
2026/10/14 22:49:57 网站建设 项目流程

做Unity开发,尤其是UI交互和战斗界面混在一起的项目,十有八九会碰到这样一个需求:鼠标按下去的那一刻,我到底该怎么拿到刚才被点中的那个UI GameObject?这个问题听起来简单,很多人第一反应是给每个按钮、每个控件手工挂脚本,界面一多就累死人,动态生成的UI更是无从下手。其实Unity自带的EventSystem早就准备好了一套完整的射线检测链路,只是大多数人只用到了IsPointerOverGameObject()这个bool判断,没有继续往下挖。

这篇文章我把这几年在项目里反复用的方案和踩过的坑整理成一份实战记录,覆盖原理拆解、三种可行方案、完整调试工具代码、多Canvas排坑、移动端触摸ID以及新输入系统的适配,做点击埋点和新手引导的同学也可以直接拿去参考。

1. 先把原理捋清楚:EventSystem 是怎么决定谁被点中的

想安稳拿到“响应点击的UI GameObject”,第一步不是写代码,而是搞清楚Unity的UI事件系统到底是怎么转起来的。很多人栽跟头,就是因为只知道查对象,不知道事件的派发路径。

1.1 三个核心角色:EventSystem、GraphicRaycaster、RaycastResult

UI点击链路里有三个关键角色,缺一个都不行。

第一个是EventSystem,场景中那个挂着EventSystem组件和StandaloneInputModule(或新输入系统下的InputSystemUIInputModule)的物体。它是事件系统的中枢,负责收集输入模块产生的鼠标、触摸、键盘事件,然后统一分发。

第二个是GraphicRaycaster,挂在Canvas上的一个组件。它负责把屏幕坐标转换成UI射线,然后对Canvas下所有Graphic(Image、Text等凡是带Graphic组件的对象)做命中测试。注意,一个Canvas上必须有这个组件,UGUI的点击检测才生效。

第三个是RaycastResult,记录一次命中的数据结构。里面包含了命中的gameObject、Graphic引用、命中距离、排序深度depth、sortingLayer、sortingOrder等信息。我们最终想要的那个UI GameObject,就是从这一堆RaycastResult里取出来的。

用一个生活化的类比来解释:点击事件就像一件快递,传送带把它送进来,GraphicRaycaster相当于扫描枪,扫出这个快递属于哪个货架,而RaycastResult就是扫描枪打印出来的那张回执单。你问“刚才哪个货架收了快递”,答案不在传送带手上,而在回执单上。所以核心操作,就是拿到并解读这份回执单。

1.2 一次点击从按下到派发,完整链路

一次最普通的鼠标左键点击,在Unity内部大致会经历这么几个步骤:

  1. StandaloneInputModule在Update中检测到鼠标按下,读取Input.mousePosition作为屏幕坐标。
  2. InputModule构造一个PointerEventData,里面携带位置、按键状态、点击次数等信息。
  3. EventSystem.RaycastAll()被调用,遍历场景里所有激活的BaseRaycaster(每个Canvas的GraphicRaycaster、以及3D物理用的PhysicsRaycaster都是它的子类),让它们分别做射线检测,并把所有命中结果收集到一个List<RaycastResult>里。
  4. 收集到的结果按渲染层级、排序等规则排序,排在最前面的就是视觉上最顶层、优先响应用户点击的对象。
  5. InputModule拿到这个排序后的列表,把点击、悬停、拖拽等事件派发给对应对象。派发时用的是ExecuteEvents.ExecuteHierarchy(),意思是:先看命中对象自己有没有实现IPointerClickHandler这类接口,没有就往它的父亲、祖父节点一路向上查找,直到找到第一个能处理事件的对象。

理解第5步特别重要。因为你点击一个按钮时,真正被射线命中的图形可能是按钮身上的Image,也可能是按钮子物体上的Text。Text没有实现点击接口,事件会向上冒泡到按钮的GameObject,按钮的Button组件内部实现了IPointerClickHandler,于是响应。也就是说,“图形命中对象”和“事件响应对象”往往是两个不同的GameObject,这也是后面做调试工具时要重点区分的地方。

1.3 为什么这个需求不能靠“塞一个脚本”解决

有同学会问:那我给每个UI控件都挂一个IPointerClickHandler脚本,在回调里拿到自己的gameObject,不就行了吗?

这个方案在UI数量很少的时候确实简单粗暴,但一旦项目跑起来,问题就来了:

  • 一个界面几十上百个控件,全手工挂脚本,预制体 Inspector 一片狼藉。
  • UI是动态生成的,每生成一个就要用代码挂一次,逻辑分散,很容易漏。
  • 做数据埋点、新手引导、全屏点击拦截这类功能时,你根本不想改业务UI的代码,只希望有一个全局的“上帝视角”知道用户点在哪。
  • 多个UI重叠时,挂在底下的脚本会收到点击吗?不一定。事件只会发给第一个命中且能够处理事件的物体,被子物体挡住后,父级目标根本拿不到回调。

所以更优雅的思路是:不依赖具体控件配合,而是主动调用EventSystem的射线检测,自己把命中结果拿过来。这就是下面三种方案要解决的事。

2. 获取UI GameObject的三种实用方案

根据需求的精细程度,可以把方案分成三个层级。我平时在项目里也是这么取舍的:先判断够不够用,不够再上更重的方案。

2.1 最省事的判断:EventSystem.current.IsPointerOverGameObject()

如果需求只是“判断当前鼠标是否点在UI上”,那IsPointerOverGameObject()两行代码就够了:

if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject()) { // 鼠标当前悬停或点击的位置在某个UI上 }

这个接口的本质,其实就是内部做了RaycastAll,然后判断命中结果数量大于0。所以它只能回答“是或否”,拿不到具体的gameObject。

它适合用在什么场景呢?最常见的是阻断3D操作。比如打开背包界面时,防止鼠标点击穿透到后面的3D场景角色身上;或者拖拽物体前先判断一下鼠标是不是按在UI上,是的话就不启动场景拖拽逻辑。

这里有一个必须记住的移动端坑:桌面端调用无参版本的IsPointerOverGameObject()没问题,但在手机上,触摸事件的判定必须传入手指ID,否则结果会不准:

// 移动端正确写法 if (Input.touchCount > 0) { Touch touch = Input.GetTouch(0); bool isOverUI = EventSystem.current.IsPointerOverGameObject(touch.fingerId); }

我在实际项目中见过不少同事,桌面端测试一切正常,打包到手机上突然所有UI点击判定全部失效,最后排查半天,就是这里少传了一个fingerId。

2.2 挂在具体控件上的方案:实现 IPointerClickHandler

当你想让某个具体控件在点击时知道“自己被点了”,最正统的做法是让它的脚本实现事件接口。比如做一个点击打点组件:

using UnityEngine; using UnityEngine.EventSystems; public class ClickLogComponent : MonoBehaviour, IPointerClickHandler { public void OnPointerClick(PointerEventData eventData) { // 拿按下时命中自己的那个射线结果 GameObject clicked = eventData.pointerPressRaycast.gameObject; Debug.Log("点击回调,响应对象:" + gameObject.name, gameObject); Debug.Log("射线命中对象:" + clicked.name, clicked); } }

这个方案的好处是侵入式但精准,事件系统和框架会自动帮你路由,你不需要关心鼠标坐标和射线检测,只需要在回调参数里取结果。

需要留意的细节是eventData里的两个字段:pointerCurrentRaycast是当前帧的射线结果,pointerPressRaycast是最初按下那一帧的射线结果。对于Click事件,系统记录的是“按下时”的对象,所以应该取pointerPressRaycast。如果你用的是IDragHandler之类需要持续追踪的接口,还要注意pointerDrag等字段的使用,这里先不展开。

这个方案适合UI本身的业务逻辑,比如点击某个按钮打开面板、切换页签,每个按钮本来就该有自己的处理入口。但如果你想做的是全局统计用户点击轨迹,总不能给所有数百个控件都挂同一个Logger吧?那就得用第三种方案。

2.3 全局无侵入的方案:EventSystem.current.RaycastAll()

这是整个问题最核心、也最通用的答案。你自己构造一份PointerEventData,手动触发一次射线检测,拿到所有命中的UI,然后按需取用。

using System.Collections.Generic; using UnityEngine; using UnityEngine.EventSystems; public static class UIRaycastUtility { private static readonly List<RaycastResult> ResultsCache = new List<RaycastResult>(); /// <summary> /// 返回屏幕坐标下所有命中的UI命中信息,结果按层级从上到下排列 /// </summary> public static List<RaycastResult> RaycastAll(Vector2 screenPosition) { ResultsCache.Clear(); if (EventSystem.current == null) { Debug.LogWarning("场景中不存在EventSystem,无法执行UI射线检测"); return ResultsCache; } var pointerEvent = new PointerEventData(EventSystem.current) { position = screenPosition }; EventSystem.current.RaycastAll(pointerEvent, ResultsCache); return ResultsCache; } /// <summary> /// 直接拿最上层的UI GameObject,没有命中UI则返回null /// </summary> public static GameObject GetTopmostUI(Vector2 screenPosition) { List<RaycastResult> results = RaycastAll(screenPosition); return results.Count > 0 ? results[0].gameObject : null; } }

把这个工具类放在项目里之后,任何地方想获取鼠标点击到的UI对象,都是一行代码的事:

GameObject uiObject = UIRaycastUtility.GetTopmostUI(Input.mousePosition); if (uiObject != null) { Debug.Log("鼠标点中了:" + uiObject.name); }

为什么说它是无侵入的?因为你不需要改动任何UI预制体,不需要给任何控件挂脚本,不管界面是静态搭的还是运行时动态生成的,只要Canvas有GraphicRaycaster,这个方法就能工作。做埋点、截图反馈、新手引导高亮、全局点击拦截,全都靠它。

当然,RaycastAll返回的是一个列表,因为一次点击可能同时命中多个Graphic。比如按钮由底图Image和标题Text组成,两个都可能被命中。列表的第一个元素是视觉上最上层的那一个,但你实际想要的“响应点击的UI”可能是它的某个父级Button,这就需要一点额外处理,我放在第3章的具体工具里详细说。

2.4 三种方案怎么选:对比与适用场景

三种方案没有绝对的优劣,我用一张表总结平时选型的标准:

方案获取对象侵入性适用场景局限
IsPointerOverGameObject拿不到对象无判断UI面板是否挡住了场景操作只能回答是否有UI,无法定位具体对象
实现IPointerClickHandler回调参数中含命中对象高,需在每个控件挂脚本控件自身的业务逻辑不适合全局统计和动态接口
EventSystem.RaycastAll拿全部命中对象无,全局一次检测埋点、导引、调试工具、穿透判断需要自己维护临时数据和排序理解

如果你只是给某个按钮写点击逻辑,写接口实现最省心。但如果你碰到的是“不管什么界面,我都要知道用户点了哪一个UI”这种全局需求,老老实实走RaycastAll,这是正统解。

3. 实操:做一个点击任意UI都能打印对象信息的调试工具

光说不练不行。这一节我直接给出一个可以复制的工具脚本,放在场景里运行,点击任意UI时,Console会输出命中列表、最上层UI对象、实际响应事件的对象以及对象路径。这个工具在开发期排查问题非常实用。

3.1 工具设计思路与完整代码

设计目标很简单:鼠标左键按下时自动跑一次RaycastAll,把命中的所有UI图形按顺序列出来,同时分析出真正会响应点击逻辑的“事件响应对象”,这样无论是UI遮挡、层级错乱还是类型搞错,一眼就能看出来。

using System.Collections.Generic; using System.Text; using UnityEngine; using UnityEngine.EventSystems; public class UIHitDebugger : MonoBehaviour { private List<RaycastResult> results = new List<RaycastResult>(); private void Update() { // 旧输入系统写法;如果是新输入系统项目,可替换为 Mouse.current.leftButton.wasPressedThisFrame if (Input.GetMouseButtonDown(0)) { DebugUIRaycast(Input.mousePosition); } } private void DebugUIRaycast(Vector2 screenPos) { if (EventSystem.current == null) { Debug.LogWarning("场景中没有EventSystem,无法执行UI射线检测"); return; } results.Clear(); var pointerEvent = new PointerEventData(EventSystem.current) { position = screenPos }; EventSystem.current.RaycastAll(pointerEvent, results); if (results.Count == 0) { Debug.Log("该位置没有命中任何UI,可能是点到了纯空白区域或3D物体"); return; } StringBuilder sb = new StringBuilder(); sb.AppendLine("本次点击共命中 " + results.Count + " 个UI图形,从上到下依次为:"); for (int i = 0; i < results.Count; i++) { RaycastResult r = results[i]; sb.AppendLine("[" + i + "] " + r.gameObject.name + " | 路径: " + GetHierarchyPath(r.gameObject.transform)); } GameObject topGO = results[0].gameObject; GameObject responder = ExecuteEvents.GetEventHandler<IPointerClickHandler>(topGO); sb.AppendLine("最上层图形对象: " + topGO.name); sb.AppendLine("实际响应点击的对象: " + (responder != null ? GetHierarchyPath(responder.transform) : "无(上层链路中没有任何点击处理器)")); Debug.Log(sb.ToString()); } private string GetHierarchyPath(Transform target) { if (target == null) { return string.Empty; } string path = target.name; Transform parent = target.parent; while (parent != null) { path = parent.name + "/" + path; parent = parent.parent; } return path; } }

把这段代码存成UIHitDebugger.cs,拖到一个空物体上,运行游戏,点击任意UI,Console里就能看到类似这样的输出:

本次点击共命中 2 个UI图形,从上到下依次为: [0] Text | 路径: HUD/Panel_Setting/Btn_Close/Text [1] Image | 路径: HUD/Panel_Setting/Btn_Close 最上层图形对象: Text 实际响应点击的对象: HUD/Panel_Setting/Btn_Close

看这个输出你就能明白,命中列表里排第一的是Text,但真正响应关闭面板逻辑的是父节点Btn_Close。GetEventHandler帮我们沿层级向上找到了它。

3.2 从命中列表中找到真正的“事件响应者”

这个查找环节最容易被忽略,但恰恰是“获取响应点击的UI GameObject”里“响应”二字的准确含义。

ExecuteEvents.GetEventHandler<T>是Unity提供的现成方法,作用是从指定GameObject开始,沿着Transform父链向上查找第一个带有T接口组件的对象,找不到就返回null。上面的工具已经用了它。

如果你想自己控制查找规则,也可以不用这个API,手动遍历:

public static GameObject FindClickResponder(GameObject hitUI) { if (hitUI == null) return null; Transform current = hitUI.transform; while (current != null) { if (current.GetComponent<IPointerClickHandler>() != null || current.GetComponent<IPointerDownHandler>() != null || current.GetComponent<IPointerUpHandler>() != null) { return current.gameObject; } current = current.parent; } return null; }

这里有一个需要注意的业务判断:不同项目对“响应者”的定义不一样。有的项目里按钮没有挂任何事件接口,而是由外层的一个UIClickLayer统一分发事件,这时候向上查找接口就找不到结果。所以实际项目里,我通常不是单纯找IPointerClickHandler,而是先看命中列表里最上层对象有没有挂在“我关心的可交互组件”(Button、Toggle、Slider等)身上的,再从业务需要的角度去决定取哪一层。

3.3 扩展:同时区分3D场景物体和UI的点击

很多项目是3D和UI混合的,点击一个地方,既可能点到UI按钮,也可能点到场景中的角色、地面、道具。这两种判定的正确顺序是:先判断UI,再做3D物理射线。

if (EventSystem.current != null && EventSystem.current.IsPointerOverGameObject()) { // 点在UI上,不允许再触发3D场景行为 return; } Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit, 200f)) { Debug.Log("点击到的3D物体:" + hit.collider.gameObject.name); }

IsPointerOverGameObject()这里再一次派上用场,因为它只用来做“挡不挡场景操作”的判断,效率高又直观。

但有一种特殊需求需要反过来想:比如游戏里的2D小地图悬浮在3D场景之上,玩家点击小地图时,既希望触发UI逻辑,又希望同时把点击坐标下发到场景里做寻路。这时候就不能直接阻断3D检测,而是要看命中列表里有没有小地图控件。如果有,把对应坐标转换后发给场景系统,然后照常执行场景射线。这种“半穿透”用RaycastAll来做就非常方便,因为你能拿到命中的具体UI,再决定是否穿透。

4. 进阶细节与容易踩的坑

方案能跑通只是第一步,项目里真正常出问题的都是各种边界情况。这一章把我在多个项目里实际遇到过的坑尽量列全。

4.1 命中结果排序:为什么拿到的不是你以为的那个UI

EventSystem.RaycastAll返回的结果,不是随机乱序的,而是按照“哪个UI在视觉上更靠上、优先响应”的顺序排列的。

具体排序规则涉及多个维度:先按sortingLayer,再按sortingOrder,再按Graphic的渲染深度depth,最后可能还会用实例ID做稳定排序。同一个Canvas内,后渲染的图形通常排列在前(比如文字的depth往往比按钮底图更靠前)。多个Canvas之间,sortingOrder更大的Canvas里的UI排在前面。

所以,results[0]可以默认理解为“视觉最顶层、当前最先接收事件的UI图形”。但注意“图形”和“业务控件”的区别,就像前面调试工具里看到的,排第一的是Text并不是按钮。如果你拿results[0]去做业务判断,很可能出现“我点击按钮却拿到Text”的困惑,不是代码错了,是理解没到位。

如果你需要的是“这个点最终会触发的第一个按钮”,一般用ExecuteEvents.GetEventHandler<IPointerClickHandler>(results[0].gameObject)向上找即可。如果你需要的是“这个点命中了哪些按钮”,就得遍历整个结果列表,逐个执行同样的向上查找并去重。

4.2 多Canvas、Event Camera和Render Mode的影响

项目里常会同时存在多个Canvas,比如主UI Canvas、头顶名字Canvas、特效提示Canvas。这些Canvas的Render Mode一旦设置不当,点击检测就会出问题。

先说规则:

  • Screen Space - Overlay模式的Canvas,不需要EventCamera,射线检测直接用屏幕坐标即可,最不容易出问题。
  • Screen Space - Camera模式的Canvas,必须给GraphicRaycaster的eventCamera字段指定一个能渲染UI的相机。如果这里没设置,或者设置的相机看不到UI层,点击检测就会全部落空。
  • World Space模式的Canvas(比如血条、漂浮文字),EventCamera同样必须正确指定,而且射线方向会受EventCamera位置影响。

多Canvas之间的点击优先级,则由Sorting Order决定。假设有两个Canvas叠在一起,一个Sorting Order是10,一个是20,点击重叠区域时,默认事件会优先给Sorting Order更大的Canvas里的UI。

我遇到过的真实案例:一个项目里把指引箭头放在了一个Sorting Order=999的Canvas上,那层Canvas被设计成全屏接收事件以显示遮罩。结果后面新加的某个弹窗UI怎么点都没反应,排查很久才发现是那个透明的箭头Canvas把点击全拦了。

所以排查“点不到目标UI”时,优先检查两件事:一是目标UI所在Canvas有没有GraphicRaycaster,二是是否有更高层级的Canvas或全屏透明的RaycastTarget挡在前面。

4.3 移动端触摸ID与新输入系统的适配

移动端的处理方式和桌面端不完全一样,有两个地方特别容易出问题。

第一个是触摸事件必须传fingerId。我在前面已经反复提过,这里再补充一下底层原因:IsPointerOverGameObject()无参版本内部会默认用-1作为pointerId,在桌面端鼠标就是这个ID,所以成立;移动端触摸的ID是0、1、2这样的手指编号,不传ID,系统就找不到对应的指针数据,导致UI判定失效。同理,用RaycastAll时,虽然我们手动传入的屏幕坐标是准确的,但如果你在移动端需要模拟pointerDown、pointerUp这类事件序列,PointerEventData里的pointerId也要对应填入。

第二个是新输入系统(Input System)项目的适配。如果你项目使用的是InputSystemUIInputModule,旧的Input.GetMouseButtonDown在默认情况下会失效,因为它读取的是旧输入管线的数据。比较好的做法是直接使用新输入系统API:

using UnityEngine.InputSystem; using UnityEngine.InputSystem.UI; // 在Update里检测鼠标按下 if (Mouse.current != null && Mouse.current.leftButton.wasPressedThisFrame) { Vector2 mousePos = Mouse.current.position.ReadValue(); UIRaycastUtility.GetTopmostUI(mousePos); } // 检测触摸 if (Touchscreen.current != null) { var touch = Touchscreen.current.primaryTouch; if (touch.press.wasPressedThisFrame) { Vector2 touchPos = touch.position.ReadValue(); UIRaycastUtility.GetTopmostUI(touchPos); } }

如果在启用了新输入系统的同时,Project Settings里把Active Input Handling设成了Both,那旧API也能用,但我建议新项目尽量统一走新API,别图省事混用。

4.4 CanvasGroup的BlocksRaycasts和动态UI的坑

CanvasGroup是UI穿透问题里一个隐形的坑。当界面淡入淡出时,很多人会把CanvasGroup的Alpha设为0,但忘记关掉BlocksRaycasts,这时候界面虽然看不见了,却依然能挡住后面的点击。反过来,如果你希望一个半透明层不接收点击,要确保BlocksRaycasts为false,而不是只调低Alpha。

动态生成UI时还有一个高频问题:运行时Instantiate出来的UI,如果忘了给所在的Canvas挂GraphicRaycaster,那这个动态UI永远不会被点击到。有些项目为了省事,把GraphicRaycaster漏了,或者把动态UI放在一个没有这个组件的新Canvas下,就会出现“能看到却点不了”的怪现象。

另外,动态生成的UI如果包含Text,务必检查RaycastTarget勾选。Text默认是开着RaycastTarget的,如果界面上放了大量纯展示文本,全开着的话每帧射线检测的开销会白白增加;反过来,如果关掉了,这个Text文字区域就不会作为UI命中对象,但点击它时事件依然会通过GraphicRaycaster查其他Graphic,所以一般展示文本建议关掉RaycastTarget,保留按钮这类交互控件的即可。

5. 常见问题排查速查表与我的实操习惯

最后一个部分,把上面涉及的所有坑浓缩成一张速查表,再分享几条我落地的习惯,方便你在项目里直接对号入座。

5.1 现象、原因、解决办法速查表

现象可能原因解决办法
手机端IsPointerOverGameObject结果不稳定没有传触摸fingerId用Input.GetTouch(0).fingerId传入
电脑上能点击,手机上点击无响应输入模块选择不一致确认使用InputSystemUIInputModule时适配新API
点击动态生成的UI没有反应Canvas缺GraphicRaycaster动态Canvas上挂GraphicRaycaster
拿到的对象总是Text而不是ButtonRaycastAll返回的是命中的Graphic列表用ExecuteEvents.GetEventHandler向上找响应者
UI可见但点击检测为空Screen Space - Camera模式下没设EventCamera给GraphicRaycaster指定正确的eventCamera
点击被高层级透明UI拦截sortingOrder较高的Canvas或全屏Image挡路检查全屏Image的RaycastTarget,或调整层级
特效层挡住了UI点击特效Canvas的GraphicRaycaster在接收点击给特效Canvas单独关掉GraphicRaycaster
CanvasGroup隐藏后仍阻挡点击BlocksRaycasts未关闭隐藏时把BlocksRaycasts置为false
RaycastAll在极端情况下抛出空引用场景切换瞬间EventSystem为空调用前判空,或者用静态工具统一处理

5.2 我在项目中落地的几个习惯

最后分享几个纯粹来自实战的习惯,不一定写进官方文档,但能省很多排查时间。

第一,统一封装调用入口。不要在每个业务模块里直接写new PointerEventData和EventSystem.current.RaycastAll,我在项目中永远维护一个静态工具类(类似上面的UIRaycastUtility),所有UI射线检测都走同一个入口。这样一旦需要加日志、做性能统计、或者适配新的输入系统,只需要改一个文件。

第二,只在点击瞬间做检测,不要在Update里每帧持续RaycastAll。虽然单次检测的开销不大,但每帧都跑一遍完全没必要,而且会产生大量临时数据和垃圾,影响GC。

第三,调试工具保留到提审包。我习惯保留UIHitDebugger这样的工具,但用一个开发宏包一层,线上包默认关闭,需要排查问题时可以通过渠道包开启。遇到玩家反馈“点了没反应”,这份命中日志能帮你秒定位到底是UI被挡、还是点击没有派发给业务逻辑。

第四,团队协作时尽量在代码注释里写清“命中对象”和“响应对象”的区别。这个词看起来简单,但不同人理解不一样,有人为了拿到按钮,直接results[0].GetComponent<Button>(),结果发现拿的是Text就显得很莫名其妙。代码注释写清楚,能少教很多新人。

这套方案我在实际项目中反复用到,确认过稳定性和效率,覆盖单机、网络联机、多Canvas、PC和移动端。做Unity UI交互相关开发的话,建议直接收藏这份,遇到相关需求就不用再从零摸索了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询