最近在做一个需要同时支持鼠标左键单击、双击、长按三种操作的小项目,切换到 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 选择Button,Binding 选择<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 设成 2,Tap Time 设成 0.3。这样当玩家在 0.3 秒内快速点击两次,Action 的performed才会触发。
如果你只需要“长按”,可以添加Hold交互,把 Min Hold Duration 设成 0.6。玩家按住 0.6 秒后performed触发,松开时canceled触发。
但这里有个大坑:当你同时需要单击、双击、长按,而且这三个功能都要响应同一个鼠标左键时,纯配置就会出问题。原因是单击的 Tap 交互在第一次点击松开后会立刻触发,系统不会等你判断“这到底是不是双击的一半”。如果你把单击做成立刻触发,双击就没机会拦截;如果你把单击做成延迟触发,单击反应就会慢半拍。
所以我在实际项目中通常不会只靠配置解决三种操作的仲裁,而是把 Input Actions 资产当作“按键信号来源”,具体判定放在代码里的状态机中。这个后面第四章会给出完整方案。
如果你确实想用纯配置方式做,有一种折中方案:单击延迟响应、双击尽快响应、长按用 Hold。具体配置如下表:
| 操作 | Action 名称 | Interaction | 关键参数 | 触发时机 |
|---|---|---|---|---|
| 单击 | SingleClick | Tap | Duration = 0.25 | 松开后 0.25 秒内没有再次按下 |
| 双击 | DoubleClick | MultiTap | Tap Count = 2, Tap Time = 0.3 | 第二次点击松开后立即触发 |
| 长按 | LongPress | Hold | Min 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 List<ISingleClickReceiver> _singleReceivers = new(); private readonly List<IDoubleClickReceiver> _doubleReceivers = new(); private readonly List<ILongPressReceiver> _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.Action<MouseClickType> 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 做事件仲裁,最后让业务层通过接口订阅。这样每一步都能跑起来,不至于一上来就被输入系统的新概念淹没。后面有空的话,我会在这个框架上扩展右键拖拽、多段连击和触屏手势处理。