Flame 游戏引擎 Tap 事件完全指南:TapCallbacks 系列 Mixin 与多点触控事件处理
【免费下载链接】flameA Flutter based game engine.项目地址: https://gitcode.com/GitHub_Trending/fl/flame
Tap(点按)事件是 Flutter 游戏引擎 Flame 中最基础、最常用的玩家交互方式之一:用户用手指触摸屏幕、用鼠标单击、或用触控笔点击,都会产生 Tap 事件。本篇技术指南以 Flame 官方文档中的 Tap Events 章节为核心,结合仓库源码(tap_callbacks.dart、multi_tap_dispatcher.dart)与官方示例(examples/lib/stories/input),系统讲解TapCallbacks、SecondaryTapCallbacks、TertiaryTapCallbacks、DoubleTapCallbacks四大 Mixin 的用法、事件对象的字段语义、长按延迟配置、底层分发机制,以及从旧版Tappable/DraggableAPI 的迁移路径。读完本文,你将能为自己游戏中的任意组件接入完整、多指安全、可自由组合的点按交互。
什么是 Tap 事件
Tap 事件是用户与 Flame 游戏交互的最基本方式之一。它的触发来源包括:手指触摸屏幕、鼠标单击、触控笔点按。一个 Tap 可以是"长按"(long tap),但整个手势期间手指不允许移动——先触摸屏幕、再移动手指、最后抬起,会被判定为拖拽(drag)而非点按;同理,鼠标在移动中按下按键也会被识别为拖拽。
Flame 正确处理同一时刻发生的多个 Tap 事件(例如用户使用多根手指同时点按),并可通过事件的pointerId属性来区分和跟踪每一次独立触摸。
关于拖拽事件本身的处理方式,可参见 Drag Events;整个输入系统的总览与坐标系约定见 Inputs 总览。
快速上手:为组件添加 TapCallbacks
要让某个组件响应点按事件,只需为其混入TapCallbacksmixin:
- 该 mixin 为组件增加四个可覆写的方法:
onTapDown、onTapUp、onTapCancel和onLongTapDown。默认情况下这些方法什么都不做,需要覆写它们才能实现实际功能。 - 此外,组件还必须实现
containsLocalPoint()方法(PositionComponent已内置实现,多数情况下无需额外处理)。Flame 通过该方法判断事件是否落在组件内部。
一个最简示例:
class MyComponent extends PositionComponent with TapCallbacks { MyComponent() : super(size: Vector2(80, 60)); @override void onTapUp(TapUpEvent event) { // 响应一次 tap 事件 } }在 tap_callbacks.dart 的源码中可以看到,mixin 的四个回调默认都是空实现,并且组件挂载(onMount)时会调用MultiTapDispatcher.addDispatcher(this),把自己注册到全局的多点触控分发器中——这也是它能自动开始接收事件的原因。
命中判定:containsLocalPoint 的作用
只有发生在组件内部的点按事件才会被投递给该组件,判断依据就是containsLocalPoint():
- 常用的
PositionComponent基于自身的size属性提供了矩形区域判定,因此务必正确设置组件尺寸; - 若组件直接继承自裸
Component,则必须手动实现containsLocalPoint(); - 若组件处于更大的组件层级中,那么只有当其父组件也正确实现了
containsLocalPoint时,它才能收到事件。
对于裸Component的手动实现示例(出自官方文档):
class MyComponent extends Component with TapCallbacks { final _rect = const Rect.fromLTWH(0, 0, 100, 100); final _paint = Paint(); bool _isPressed = false; @override bool containsLocalPoint(Vector2 point) => _rect.contains(point.toOffset()); @override void onTapDown(TapDownEvent event) => _isPressed = true; @override void onTapUp(TapUpEvent event) => _isPressed = false; @override void onTapCancel(TapCancelEvent event) => _isPressed = false; @override void render(Canvas canvas) { _paint.color = _isPressed ? Colors.red : Colors.white; canvas.drawRect(_rect, _paint); } }另外,FlameGame本身也是一个Component,因此可以直接把TapCallbacks混入游戏类来接收全局点按,无需任何包装组件(inputs.md 中明确说明了这一点,官方示例 multitap_advanced_example.dart 正是把TapCallbacks与DragCallbacks同时混入FlameGame的用法)。
Tap 事件的完整生命周期
一次完整的点按手势由一系列事件组成。本节的四个小节分别对应TapCallbacks的四个回调,并补充其事件对象在源码中的字段定义。
onTapDown:每次点按的起点
每次点按都以 "tap down" 事件开始,通过void onTapDown(TapDownEvent)处理器接收。事件被投递给触摸点处第一个带有TapCallbacksmixin 的组件;正常情况下事件到此停止传播。若希望事件继续传递给下方的组件,可将event.continuePropagation设为true(仅onTapDown需要这样做,其余事件默认会传递)。
TapDownEvent携带了事件的完整信息(源码见 tap_down_event.dart):
event.localPosition:事件在当前组件局部坐标系中的坐标;event.canvasPosition:事件在整个游戏画布坐标系中的坐标;event.devicePosition:事件在设备屏幕坐标系中的坐标;event.pointerId:本次触摸的唯一标识;event.deviceKind:产生事件的设备类型(PointerDeviceKind,如触摸、鼠标、触控笔)。
关于三种坐标系更精确的定义,Inputs 总览 给出了权威说明:devicePosition相当于 Flutter 原生事件的globalPosition;canvasPosition相对于GameWidget,是 Flame 层面的"全局坐标";localPosition是应用了整条父级变换链(含相机)之后相对当前接收组件的坐标。
在 multi_tap_dispatcher.dart 中可以看到,onTapDown通过event.deliverAtPoint(...)找到触摸点下的组件,并用_record集合记录下(pointerId, component)配对——这是后续onTapUp/onTapCancel能找到"谁曾收到过按下事件"的关键。任何收到过onTapDown的组件,最终都会收到同一pointerId的onTapUp或onTapCancel。
onLongTapDown:长按
如果用户按住手指一段时间,"长按"就会被触发,调用void onLongTapDown(TapDownEvent)处理器——它只会投递给之前收到过onTapDown的组件。
长按延迟由TapConfig.longTapDelay控制,默认值为300 毫秒(可能与系统默认值不同)。可通过TapConfig.longTapDelay修改,这在无障碍等特殊需求场景下很有用:
TapConfig.longTapDelay = 0.5; // 单位:秒从 tap_config.dart 源码可见更多细节:
- 默认值
_defaultLongTapDelay = 0.3(秒); - 设置器带有最小值保护
_minLongTapDelay = 0.15(秒),低于该值的设置会被钳制到 0.15——因为过低的延迟会让"长按"退化成普通点按; - 对应测试 tap_config_test.dart 验证了默认值为 0.3、可被修改、且不能低于 0.15 这三个行为。
底层实现上,multi_tap_dispatcher.dart 在注册MultiTapGestureRecognizer时会把longTapDelay从秒转换为Duration(milliseconds: ...)传给 Flutter 手势识别器。
onTapUp:点按成功完成
onTapUp表示点按序列成功完成,保证只投递给之前用相同 pointer id 收到过onTapDown的组件(multi_tap_dispatcher.dart 中通过_record.remove(...)来校验并清除记录)。
TapUpEvent(源码见 tap_up_event.dart)包含手指抬起瞬间的坐标信息与pointerId。值得注意的是:
- tap-up 的设备坐标与对应 tap-down 的设备坐标相同(或非常接近);
- 但局部坐标可能大不相同——如果被点按的组件正在移动(游戏中很常见),local 坐标可能偏差很大;
- 极端情况下,组件移动离开了触摸点,
onTapUp根本不会产生,而是被onTapCancel取代。需要留意的是:此时的onTapCancel是在用户抬起或移动手指的那一刻产生的,而不是在组件移开的那一刻。
onTapCancel:点按流产
onTapCancel在点按"未能实现"时触发。最常见的场景是用户移动了手指,手势从"tap"变成了"drag";较少见的是被点按的组件从用户手指下移开;更罕见的情况包括:其他 widget 覆盖到了游戏 widget 之上、设备熄屏等。
TapCancelEvent(源码见 tap_cancel_event.dart)只包含被取消的那个TapDownEvent的pointerId,不携带任何位置信息。这也与直觉一致:取消不发生在某个特定的坐标点上。
源码中,multi_tap_dispatcher.dart 的_tapCancelImpl会遍历_record,把所有匹配该pointerId的组件逐一派发onTapCancel并从记录中移除——这保证了"按下后必以 Up 或 Cancel 收尾"的不变式。
进阶 Mixin:右键、中键与双击
除了默认的TapCallbacks(对应桌面端的左键),Flame 还提供了三个独立可组合的进阶 Mixin。
SecondaryTapCallbacks:次要点按(桌面右键)
混入SecondaryTapCallbacks即可接收次要点按事件(桌面端对应鼠标右键):
class MyComponent extends PositionComponent with SecondaryTapCallbacks { @override void onSecondaryTapUp(SecondaryTapUpEvent event) { /// 处理 } @override void onSecondaryTapCancel(SecondaryTapCancelEvent event) { /// 处理 } @override void onSecondaryTapDown(SecondaryTapDownEvent event) { /// 处理 } }可以在同一个组件上同时混入TapCallbacks和SecondaryTapCallbacks,分别接收主、次点按事件。仓库中还有对应的完整示例 secondary_tap_callbacks_example.dart。
TertiaryTapCallbacks:第三要点按(鼠标中键)
与上类似,TertiaryTapCallbacks用于接收第三要点按事件(桌面端对应鼠标中键):
class MyComponent extends PositionComponent with TertiaryTapCallbacks { @override void onTertiaryTapUp(TertiaryTapUpEvent event) { /// 处理 } @override void onTertiaryTapCancel(TertiaryTapCancelEvent event) { /// 处理 } @override void onTertiaryTapDown(TertiaryTapDownEvent event) { /// 处理 } }TapCallbacks、SecondaryTapCallbacks、TertiaryTapCallbacks三者可以同时混入同一个组件,独立接收主、次、第三要点按事件。完整示例见 tertiary_tap_callbacks_example.dart。
从实现上看,主点按通过MultiTapDispatcher分发,且注册手势识别器时使用了allowedButtonsFilter: (buttons) => buttons == kPrimaryButton(multi_tap_dispatcher.dart),即只有主按键(左键)才会触发;次级与第三级点按则由 non_primary_tap_dispatcher.dart 单独处理。
DoubleTapCallbacks:双击
DoubleTapCallbacks用于接收组件的双击事件(源码见 double_tap_callbacks.dart):
class MyComponent extends PositionComponent with DoubleTapCallbacks { @override void onDoubleTapUp(DoubleTapEvent event) { /// 处理 } @override void onDoubleTapCancel(DoubleTapCancelEvent event) { /// 处理 } @override void onDoubleTapDown(DoubleTapDownEvent event) { /// 处理 } }源码注释中有一个重要的平台限制需要了解:Flutter 目前只能同时识别一个双击事件。这意味着如果在两个相距很远的DoubleTapCallbacks组件上同时双击,只会触发其中一个回调(甚至一个都不触发)。双击事件的完整示例见 double_tap_callbacks_example.dart。
源码视角:事件如何被分发
理解底层分发机制有助于写出更符合预期的事件处理代码。以TapCallbacks为例,其事件链路如下:
- 注册:组件
onMount时通过MultiTapDispatcher.addDispatcher(this)把自己与分发器关联(tap_callbacks.dart)。 - 手势接入:分发器挂载时向
gameRef.gestureDetectors注册 Flutter 的MultiTapGestureRecognizer,并绑定onTap、onTapDown、onTapUp、onTapCancel、onLongTapDown五个原生回调(multi_tap_dispatcher.dart)。这正是"Flame 的输入系统构建于 Flutter 手势 widget 之上"的体现——Inputs 总览 指出其底层是GestureDetector、RawGestureDetector与MouseRegion。 - 坐标转换:原生事件被包装为
TapDownEvent/TapUpEvent/TapCancelEvent,PositionEvent基类(position_event.dart)在构造时保存devicePosition,访问canvasPosition时调用game.convertGlobalToLocalCoordinate(...)换算画布坐标,localPosition则在事件沿组件树投递时由renderingTrace提供。 - 命中与投递:
deliverAtPoint调用rootComponent.componentsAtPoint(...)找出触摸点下的组件链,优先投递给最上层组件;只有显式设置continuePropagation才会继续向下传递。 - 状态记账:
_record集合以(pointerId, component)为键记录"谁正在被按下",确保 Up/Cancel 只发给真正按下的组件,并保证同一个pointerId的多指触摸互不干扰。
对于多指触摸,MultiTapGestureRecognizer天然支持多指针;pointerId贯穿 Down → Up/Cancel 全程,这也是示例 multitap_example.dart 能同时跟踪多根手指的原因。进阶示例 multitap_advanced_example.dart 则展示了TapCallbacks与DragCallbacks混用:按下时绘制矩形、拖动时绘制拖拽框,并用event.pointerId作为字典键维护每根手指各自的矩形状态。
关于坐标系的一个使用警告
Inputs 总览 特别提醒:localPosition与localDelta是相对于"当前正在接收事件的组件"的,因此只能在回调内部读取,不要保存事件之后再读取——投递结束后这些值不再被维护,根据事件类型不同,你会拿到残留值或直接报错。如果之后还需要坐标,请在回调内用event.localPosition.clone()拷贝一份。
提高命中精度的补充:GestureHitboxes
若矩形命中区域不够精确(例如圆形岩石图片的角落没有内容),可在组件上混入GestureHitboxes来定义更贴合形状的圆形、多边形等命中盒,事件传播时会依据这些命中盒判定。命中盒的具体定义方式与Collidable用法一致,详见碰撞检测文档中的 ShapeHitbox 章节,可参考 gesture_hitboxes_example.dart 的完整实现。
从 Tappable/Draggable 迁移到新 API
如果你已有使用旧版Tappable/Draggablemixin 的游戏,可按以下步骤迁移到本文所述的新 API:
- 把所有使用这两个 mixin 的组件替换为
TapCallbacks/DragCallbacks。 - 调整
onTapDown、onTapUp、onTapCancel、onLongTapDown四个方法的签名:- 原来
(int pointerId, TapDownDetails details)这样的参数对,替换为单一事件对象TapDownEvent event; - 不再有返回值。若需要让组件把点按透传给下方的组件,改为设置
event.continuePropagation = true。这仅对onTapDown有必要,其他事件默认自动透传; - 需要触摸点坐标时,使用
event.localPosition而非手动计算;event.canvasPosition与event.devicePosition也可用; - 如果组件挂接在自定义祖先节点下,请确保该祖先尺寸正确或实现了
containsLocalPoint()。
- 原来
小结与最佳实践
- 默认优先用
PositionComponent+TapCallbacks:矩形命中由size自动决定,零样板代码。 - 牢记 Up/Cancel 配对不变式:收到
onTapDown的组件最终必收到onTapUp或onTapCancel(同一pointerId),可据此可靠地维护按下状态,例如用pointerId作键维护多指状态字典(见 multitap_advanced_example.dart)。 - 区分手势类型:手指移动会把手势从 tap 转为 drag,因此需要拖动反馈时应同时混入
DragCallbacks处理onDragStart/Update/End。 - 需要"透传"时才用
continuePropagation:默认只投递给最上层组件,只有显式开启才会传递给下方组件,且只在onTapDown上生效。 - 长按延迟按需配置:
TapConfig.longTapDelay默认 0.3 秒、下限 0.15 秒,可在应用启动时按无障碍需求调整。 - 平台差异留个心眼:双击事件在当前 Flutter 版本下无法同时追踪多个,涉及多指双击交互时要自行规避。
如需继续深入,可阅读同目录下的 Drag Events、Long Press Events、Pointer Events,或直接在仓库中运行官方示例examples(入口见 examples/README.md)进行交互验证。
【免费下载链接】flameA Flutter based game engine.项目地址: https://gitcode.com/GitHub_Trending/fl/flame
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考