Unity新输入系统实现鼠标单击、双击、长按判定与事件分发框架

发布时间:2026/10/2 22:08:11
Unity新输入系统实现鼠标单击、双击、长按判定与事件分发框架 最近在做一个需要同时支持鼠标左键单击、双击、长按三种操作的小项目切换到 Unity 的 New Input System 之后第一版代码写得非常简陋一个 MonoBehaviour 里塞满了 if 判断、时间戳和一堆“状态变量”。加功能只敢加在同一个文件里后来代码长得自己都不想看。这一篇我打算把这块拆开重做。核心思路其实不复杂底层用 New Input System 的 API 读取鼠标输入把“按下了多久、第二次点击间隔多少”这类原始信息归一化成“单击、双击、长按”三个事件上层再用接口多态把这些事件分发给不同的业务接收者。这样不管是角色选择、UI 操作还是游戏里的长按蓄力都能共用同一套鼠标判定逻辑。如果你正好在纠结“新输入系统怎么配置鼠标交互”“单击双击长按怎么同时判定”“接口多态到底怎么落地”这篇文章应该能帮你省不少时间。这一篇先把基础框架讲清楚等后面有需要再扩展右键、拖拽和触屏输入。1. 为什么要用 New Input System以及这一篇要解决什么1.1 新输入系统到底解决了什么问题Unity 的老输入系统Input.GetMouseButtonDown(0)写起来确实快但它的短板在项目变大之后会非常明显。最典型的是“输入设备绑定写死在代码里”比如我要把鼠标左键改成键盘空格触发就得去翻代码把每个Input.GetMouseButtonDown(0)改掉如果是触屏、手柄、键盘都要支持同一套交互老方案基本靠手写一堆if。New Input System 的核心变化是“输入动作”这个概念你先定义“玩家可能执行什么操作”再告诉系统“哪些设备上的哪些按键可以触发这个操作”。鼠标左键、触摸屏点按、手柄 A 键都可以绑到同一个 Action 上业务层只需要监听这个 Action完全不管设备差异。新输入系统还自带了 Interaction 机制比如 Tap、MultiTap、Hold 这些交互模式可以在配置面板里完成。不过真要把单击、双击、长按同时判断配置面板只能解决一部分问题另一半需要在代码里做状态管理这个我后面会详细讲。1.2 三种点击操作的本质单击、双击、长按看起来是三个互相独立的事件但在物理层面它们都来自同一个鼠标左键按下、松开、再次按下、再次松开。真正的差异只有两个维度按下持续的时间长短用于区分“短按”和“长按”。两次完整点击之间的间隔用于区分“单击”和“双击”。所以你在做这类功能时不要一开始就把它们当成三个孤立功能。把“按下”“松开”“按下时长”“上次点击时间”这些原始状态统计出来再根据阈值做仲裁才是正常的思路。双击最麻烦的一点是它天然依赖“等待第二次点击”这个动作。如果你在第一次点击松开后立刻触发单击那么第二次点击到来时单击已经发出去了双击就没办法“事后收回”。反过来如果你每次都延迟一点再触发单击普通点击的响应速度又会被拖慢。这个矛盾没有完美的默认解只能根据项目玩法做取舍。1.3 接口多态的设计意图我以前写过很多“堵代码”式的输入处理if (clickType ClickType.Single) { unit.Select(); } else if (clickType ClickType.Double) { camera.Focus(unit); } else if (clickType ClickType.LongPress) { skill.ChargeStart(); }这种写法在业务逻辑少的时候还能接受一旦十几个系统都要响应鼠标操作这个 if-else 会越写越长而且每个系统都要暴露自己的方法输入模块和业务模块完全耦合。用接口多态之后思路反过来了输入模块不知道也不关心“谁来处理单击”它只负责把“单击发生”这个事实广播出去。具体处理逻辑由业务系统自己实现接口并注册进来。这样输入模块和业务模块之间就只有接口协议的关系谁需要处理什么功能谁就实现对应接口。这一篇的代码我会按照这个思路来组织不搞花哨设计但一定让后面加功能更轻松。2. 环境准备与 Input Actions 配置2.1 安装 Input System 包和切换输入后端用新输入系统第一步不是写代码而是把包装上并切换输入后端。Window - Package Manager搜索Input System点击 Install 即可。装完包之后Unity 会提示你是否启用新输入后端并重启编辑器。如果你点了 No后面还需要手动切换Edit - Project Settings - Player - Active Input Handling把它改成Input System Package (New)或Both。这里我建议选Both而不是纯新输入系统。原因很实际Unity 的某些老 UI 插件、第三方 Tween 插件还没有完全适配新输入系统选Both可以兼容旧代码而且Input.GetMouseButtonDown这类旧 API 仍然可用方便迁移期间做临时调试。等所有代码都迁移过去了再切成纯新输入系统也不迟。注意切换 Active Input Handling 之后必须重启 Unity 编辑器不然会一直报InvalidOperationException: You are trying to read Input using the UnityEngine.Input class, but you have switched active Input handling to Input System package。这个报错的本质就是输入后端还没切换干净。2.2 新建 Input Actions 资产在 Project 窗口右键 - Create - Input Actions命名为PlayerInputActions.inputactions。双击打开后是一个可视化编辑器左边是 Action Maps中间是 Actions右边是 Bindings 和 Interactions。我的建议是至少创建一个 Action Map名字叫Gameplay然后在里面新建一个名为LeftClick的 Action。Action Type 选择ButtonBinding 选择Mouse/leftButton。你可以把Mouse先拖到预览窗口里也可以直接在 Binding Path 里手写Mouse/leftButton两者效果一样。这样配置完成之后你已经在数据层声明了“我的项目里有一件事叫 LeftClick它由鼠标左键触发”。后面不管是把按键改成键盘、触屏还是手柄都只需要改这个资产不用改业务代码。2.3 LeftClick Action 的交互配置细节New Input System 的 Action 可以挂 Interaction默认提供的常用交互有 Tap、MultiTap、Hold、SlowTap、Press 等。如果你只需要“双击”这一个功能可以直接在LeftClick上添加MultiTap交互把 Tap Count 设成 2Tap Time 设成 0.3。这样当玩家在 0.3 秒内快速点击两次Action 的performed才会触发。如果你只需要“长按”可以添加Hold交互把 Min Hold Duration 设成 0.6。玩家按住 0.6 秒后performed触发松开时canceled触发。但这里有个大坑当你同时需要单击、双击、长按而且这三个功能都要响应同一个鼠标左键时纯配置就会出问题。原因是单击的 Tap 交互在第一次点击松开后会立刻触发系统不会等你判断“这到底是不是双击的一半”。如果你把单击做成立刻触发双击就没机会拦截如果你把单击做成延迟触发单击反应就会慢半拍。所以我在实际项目中通常不会只靠配置解决三种操作的仲裁而是把 Input Actions 资产当作“按键信号来源”具体判定放在代码里的状态机中。这个后面第四章会给出完整方案。如果你确实想用纯配置方式做有一种折中方案单击延迟响应、双击尽快响应、长按用 Hold。具体配置如下表操作Action 名称Interaction关键参数触发时机单击SingleClickTapDuration 0.25松开后 0.25 秒内没有再次按下双击DoubleClickMultiTapTap Count 2, Tap Time 0.3第二次点击松开后立即触发长按LongPressHoldMin Hold Duration 0.6按住达到 0.6 秒后触发这种配置的优点是不用写状态机三个 Action 直接挂performed回调。缺点是单击事件一定会比物理点击晚约 0.25 秒触发对操作手感敏感的游戏来说会非常难受。如果你的项目能接受这个延迟那直接用配置最省事。2.4 Generate C# Class 与 Action 的启用时机在 Input Actions 资产面板右上角有一个Generate C# Class选项。勾选之后Unity 会自动生成一个强类型的 C# class比如PlayerInputActions。这样你就不用通过字符串名称去查 Action而是直接用inputActions.Gameplay.LeftClick这样的强类型访问写起来不容易拼错字符串IDE 也能自动补全。这里我强烈建议生成 C# Class。字符串方式在项目小的时候无所谓但等你改了 Action 名称编译期不会报错运行期才会黑屏排查成本就高了。生成类之后Action 名称一变代码也马上编译报错反而安全。启用 Action 的标准方式如下using UnityEngine; using UnityEngine.InputSystem; public class InputBootstrap : MonoBehaviour { private PlayerInputActions _inputActions; private void Awake() { _inputActions new PlayerInputActions(); _inputActions.Enable(); } private void OnDestroy() { _inputActions?.Dispose(); } }注意OnDestroy里要调用Dispose()否则生成的类会持有未释放的事件处理器场景切换次数多了容易造成内存泄漏。3. 接口抽象与多态框架3.1 用接口把三种操作“收口”我先定义三个业务接口分别对应单击、双击、长按。这样每个业务模块只实现自己关心的方法不需要被迫接收所有事件。using UnityEngine; public interface ISingleClickReceiver { void OnSingleClick(Vector2 screenPosition); } public interface IDoubleClickReceiver { void OnDoubleClick(Vector2 screenPosition); } public interface ILongPressReceiver { void OnLongPress(Vector2 screenPosition, float duration); void OnLongPressCanceled(Vector2 screenPosition, float duration); }这里的多态体现在 Router 只面向接口编程。Router 不管你的业务类到底是 UIShop、UnitSelector 还是 SkillController它只认识ISingleClickReceiver、IDoubleClickReceiver、ILongPressReceiver这三个“插槽”。任何类只要实现了对应接口注册进来就能收到事件。这种写法的好处是“依赖倒置”底层鼠标判定模块不依赖任何上层业务类上层业务类也不依赖底层具体实现两边都依赖接口协议。以后哪怕要把输入源从鼠标换成触屏业务层的OnSingleClick、OnDoubleClick也完全不用改。3.2 事件分发器 Router 的实现Router 承担三个职责接收底层输入状态、根据状态判定事件类型、把事件分发给已注册的接收者。using System.Collections.Generic; using UnityEngine; using UnityEngine.InputSystem; public class MouseClickRouter : MonoBehaviour { [SerializeField] private float _doubleClickTime 0.25f; [SerializeField] private float _longPressTime 0.6f; private readonly ListISingleClickReceiver _singleReceivers new(); private readonly ListIDoubleClickReceiver _doubleReceivers new(); private readonly ListILongPressReceiver _longPressReceivers new(); private float _lastClickTime -100f; private Vector2 _lastClickPosition; private bool _hasPendingClick; private bool _isPressing; private float _pressStartTime; private Vector2 _pressStartPosition; private bool _longPressTriggered; public void Register(object receiver) { if (receiver is ISingleClickReceiver single) { _singleReceivers.Add(single); } if (receiver is IDoubleClickReceiver doubleClick) { _doubleReceivers.Add(doubleClick); } if (receiver is ILongPressReceiver longPress) { _longPressReceivers.Add(longPress); } } public void Unregister(object receiver) { if (receiver is ISingleClickReceiver single) { _singleReceivers.Remove(single); } if (receiver is IDoubleClickReceiver doubleClick) { _doubleReceivers.Remove(doubleClick); } if (receiver is ILongPressReceiver longPress) { _longPressReceivers.Remove(longPress); } } private void Update() { Mouse mouse Mouse.current; if (mouse null) { return; } if (mouse.leftButton.wasPressedThisFrame) { _isPressing true; _longPressTriggered false; _pressStartTime Time.unscaledTime; _pressStartPosition mouse.position.ReadValue(); } if (_isPressing mouse.leftButton.isPressed !_longPressTriggered Time.unscaledTime - _pressStartTime _longPressTime) { _longPressTriggered true; NotifyLongPress(_pressStartPosition, _longPressTime); } if (mouse.leftButton.wasReleasedThisFrame) { float pressDuration Time.unscaledTime - _pressStartTime; if (_longPressTriggered) { _isPressing false; NotifyLongPressCanceled(_pressStartPosition, pressDuration); _hasPendingClick false; _lastClickTime -100f; return; } _isPressing false; if (_hasPendingClick Time.unscaledTime - _lastClickTime _doubleClickTime) { Vector2 releasePosition mouse.position.ReadValue(); _hasPendingClick false; NotifyDoubleClick(releasePosition); } else { _lastClickTime Time.unscaledTime; _lastClickPosition mouse.position.ReadValue(); _hasPendingClick true; } } if (_hasPendingClick Time.unscaledTime - _lastClickTime _doubleClickTime) { _hasPendingClick false; NotifySingleClick(_lastClickPosition); } } private void NotifySingleClick(Vector2 position) { for (int i _singleReceivers.Count - 1; i 0; i--) { _singleReceivers[i].OnSingleClick(position); } } private void NotifyDoubleClick(Vector2 position) { for (int i _doubleReceivers.Count - 1; i 0; i--) { _doubleReceivers[i].OnDoubleClick(position); } } private void NotifyLongPress(Vector2 position, float duration) { for (int i _longPressReceivers.Count - 1; i 0; i--) { _longPressReceivers[i].OnLongPress(position, duration); } } private void NotifyLongPressCanceled(Vector2 position, float duration) { for (int i _longPressReceivers.Count - 1; i 0; i--) { _longPressReceivers[i].OnLongPressCanceled(position, duration); } } }这段代码的核心是状态仲裁。_lastClickTime记录第一次点击松开的时间_hasPendingClick表示“我在等待第二次点击”。如果第二次点击在_doubleClickTime内到达就判定为双击如果超过_doubleClickTime还没来就补发单击。Time.unscaledTime是故意用的而不是Time.time。因为很多游戏在点击判定期间可能使用Time.timeScale 0暂停如果用Time.time时间会停止流动点击判定就会卡住。3.3 具体接收者如何注册假设我有一个单位选择系统单击选中单位双击聚焦单位长按打开单位详情面板。它只需要实现对应的接口然后把自己注册到 Router。using UnityEngine; public class UnitInteractionPanel : MonoBehaviour, ISingleClickReceiver, IDoubleClickReceiver, ILongPressReceiver { [SerializeField] private MouseClickRouter _router; private void OnEnable() { _router.Register(this); } private void OnDisable() { _router.Unregister(this); } public void OnSingleClick(Vector2 screenPosition) { // 执行选中逻辑 } public void OnDoubleClick(Vector2 screenPosition) { // 执行镜头聚焦逻辑 } public void OnLongPress(Vector2 screenPosition, float duration) { // 打开详情面板 } public void OnLongPressCanceled(Vector2 screenPosition, float duration) { // 长按触发后如果提前松开的收尾逻辑 } }这里我特意用OnEnable/OnDisable来注册和注销而不是Start/OnDestroy。这样对象被禁用时不会继续收到事件重新激活时又能自动恢复接收。比如暂停界面打开时把某些面板 disable 掉鼠标操作就不会穿透到背后的业务逻辑。4. 单击、双击、长按判定与实战实现4.1 为什么这里选择“时间戳状态机”而不是纯 Interaction 配置上一章说过Input Actions 配置里的 Tap、MultiTap、Hold 适合单个操作不适合多操作仲裁。如果三个 Action 同时绑定同一个鼠标按键你会发现事件触发顺序完全不靠谱第一次点击松开时Tap 交互的单击 Action 立刻performed。如果接着第二次点击MultiTap 交互的双击 Action 在第二次松开时performed。这会导致单击事件已经发出去下一秒又来一个双击事件业务层很难根据时序做补偿。如果你只有一个接收者可以在单击事件里做延迟等doubleClickTime过去再真正执行能回避这个问题。但多个系统同时监听时每个系统都自己处理延迟逻辑会很乱。所以我更倾向于只把 Input System 当“设备层”用Mouse.current拿到原始按键状态自己在 Update 里维护一套小状态机。这样事件仲裁逻辑只写一次所有业务接收者面对的都是一套干净、有序的事件流。4.2 核心判定逻辑逐行拆解以 Router 的 Update 为例我把核心判定逻辑拆成四个阶段按下阶段mouse.leftButton.wasPressedThisFrame只会在一帧里为 true适合记录按下时间。这里一定要把起始位置也记下来因为长按事件需要知道玩家是按在哪个屏幕坐标上的。如果不记录等到长按触发时鼠标可能已经移动了一段距离。长按判定阶段按住不松开时每一帧都检查Time.unscaledTime - _pressStartTime _longPressTime。一旦达到阈值立刻发送OnLongPress并把_longPressTriggered置为 true防止同一段按压重复触发。这里有个容易忽略的细节长按触发后不要立刻把_isPressing置为 false否则松开时的wasReleasedThisFrame状态会丢失导致无法发送OnLongPressCanceled。正确做法是保留_isPressing在松开时通过_longPressTriggered判断到底该走“长按结束”还是“短按点击”分支。松开判定阶段松开时先看是不是长按已经触发过。如果触发过长按松开只负责通知取消/结束不参与单击/双击判定。如果还没触发长按说明这次是短按。此时检查是否存在_hasPendingClick并且这次松开距离上次松开的时间是否在_doubleClickTime内。满足条件就发双击不满足则把这次松开当成“潜在的第一次点击”开始等待第二次点击。延迟补发阶段每一帧最后都要检查_hasPendingClick是否超时。一旦超过_doubleClickTime就补发单击事件。这一步的意思是如果玩家只点了一下系统不能永远等下去必须在超时后把这次点击“定案”为单击。之所以放在 Update 最末尾是因为输入事件发生时我们可能在这一帧刚记录了_lastClickTime要避免同帧内再次触发补发逻辑。4.3 三种操作的触发顺序与参数调整把这套状态机跑起来后你会得到如下时序玩家操作第一次松开第二次松开按住达到阈值最终事件顺序快速点两下记录时间等待判定双击无DoubleClick只点一下记录时间等待无无超时后 SingleClick长按无无触发 LongPress松开时 LongPressCanceled参数_doubleClickTime最常见的取值是 0.2 到 0.3 秒。取值太小手指稍慢一点就判定成两次单击取值太大单击响应延迟明显操作手感发闷。策略类、即时战略类游戏我习惯用 0.25 秒这种游戏里双击频率不高单击也不会太依赖极速反馈。_longPressTime我一般取 0.5 到 0.8 秒。太短容易和单击抢事件太长玩家会觉得“按下去没反应”。如果你做的是蓄力类玩法最好把这个参数做成可配置不要让策划每次来找你改代码。注意这里用的延迟单击方案有一个固有延迟成本等于_doubleClickTime的时长。也就是说玩家单击后业务层收到OnSingleClick的时间会比物理点击晚约 0.25 秒。如果你的项目对手感要求极高可以考虑“即时单击 双击反悔”的变体第一次点击立刻触发OnSingleClick如果后续判定为双击再给接收者发送一个类似OnDoubleClickCancelSingle的补偿事件。不过接收者必须支持“撤回刚才的单击效果”实现成本会高不少。4.4 用 Router 把判定结果交给业务层Router 已经通过接口把事件广播出去了业务层不需要知道输入底层怎么判定。但为了调试方便建议在 Router 上暴露一个 C# 事件方便在 Inspector 里看到当前状态。public event System.ActionMouseClickType OnMouseClickTypeChanged; public enum MouseClickType { None, Single, Double, LongPress }然后在触发三种事件时加一行OnMouseClickTypeChanged?.Invoke(...)。这样你可以在测试场景里挂一个测试组件把当前事件输出到屏幕上快速验证判定逻辑是否符合预期。如果后续要加多段连击、三击、拖拽判定这个事件也可以作为扩展观察点。5. 常见问题与调试技巧5.1 单击总被吞掉 / 双击容易误触这两个问题的方向相反但根因都是_doubleClickTime设置不合理。单击总被吞掉说明阈值太长系统把两次操作当成了一次双击。双击容易误触说明阈值太短玩家手速稍快但确实想双击时第二次点击已经被当成了独立单击。解决办法是把参数暴露到 Inspector 或者脚本的配置数据里测试时多调几次。我一般会做一个小工具屏幕上显示最近一次鼠标按下的时间戳和上次点击间隔这样能非常直观地看到玩家的实际操作节奏而不是靠感觉猜阈值。5.2 长按触发后松开又被判定成单击这是最经典的踩坑现场。原因通常是代码在长按触发后没有“消费”掉这次按键状态松开时的wasReleasedThisFrame仍然走进了单击判定分支。在 Router 中我已通过_longPressTriggered做了拦截如果长按已经触发松开时直接走NotifyLongPressCanceled并return完全不再处理单击/双击逻辑。这个分支千万别省略否则你会发现玩家长按之后人物还会额外执行一次单击选择非常影响体验。5.3 UI 与场景点击互相干扰如果你的项目有 UI 界面鼠标点击在 UI 上和场景中都应该生效但通常场景点击不应该穿透 UI 面板。此时需要在 Router 的 Update 开头做一次 UI 拦截判断。判断方式很简单在按钮点击的 Mouse Down 阶段记录EventSystem.current.IsPointerOverGameObject()如果是 UI 上就忽略这次点击。但注意IsPointerOverGameObject()在鼠标按住和松开时结果可能不同好的做法是在wasPressedThisFrame时记录一个_blockedByUI标记这一整次按压周期都沿用这个标记。5.4 真机/编辑器里鼠标事件失效首先要确认Mouse.current是否为 null。如果你在编辑器里测试需要确保脚本被切换到新输入系统后端。如果你在触屏设备上测试Mouse.current不一定会有值这时候应该改用Touchscreen.current。还有一个隐藏点新的 Input System 默认支持“运行时重绑定”和“设备丢失警告”如果设备没有正确识别Input Debugger 面板里会有提示。打开 Window - Analysis - Input Debugger可以看到当前所有输入设备的状态如果鼠标设备不在列表里基本就是驱动或后端切换的问题。5.5 调试 Input System 的看家手段调试新输入系统我总结了一套固定流程先用 Input Debugger 看设备是否被识别。再用 InputActions 资产的 Interactive Debugger点击右上角Open Input Debugger看按钮/按键的当前状态。然后在业务代码里打日志把_lastClickTime、_hasPendingClick、_longPressTriggered等状态变量输出到 Console。最后再回查是原始输入没进来还是状态机判断逻辑有问题。这套流程能覆盖大多数输入问题。我遇到的最频繁的错误不是 API 用错了而是后端没有切干净导致新旧两套输入同时工作事件触发重复、奇怪。遇到这种问题先把 Player Settings 里的 Active Input Handling 确认一遍再提 Debugger基本就能定位。这一篇的鼠标左键单击、双击、长按基础框架到这里就完整了。如果你也想在项目里引入新输入系统我的建议是不要一上来就套复杂框架先把 Input Actions 资产建好再用一个小的 Router 做事件仲裁最后让业务层通过接口订阅。这样每一步都能跑起来不至于一上来就被输入系统的新概念淹没。后面有空的话我会在这个框架上扩展右键拖拽、多段连击和触屏手势处理。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询