输入与角色移动
2.1 触屏和键盘:两套输入,归成一个"意图"
Skyline Courier 是个手机游戏,但我在 PC 上调试的次数比真机还多。所以输入一开始就是两套:手机上的手指拖拽,PC 上的方向键加鼠标。这两套东西如果各写各的分支,代码很快会长成一坨。
我的做法是:不管来源是什么,最终只产出一个东西:一个方向意图。这个意图有两个字段:有没有在"推",以及推向哪。
import { _decorator, Component, Input, input, Vec2 } from 'cc';
const { ccclass } = _decorator;
@ccclass('PlayerInput')
export class PlayerInput extends Component {
private _moveDir = new Vec2();
onEnable () {
input.on(Input.EventType.TOUCH_START, this.onTouchStart, this);
input.on(Input.EventType.TOUCH_MOVE, this.onTouchMove, this);
input.on(Input.EventType.TOUCH_END, this.onTouchEnd, this);
input.on(Input.EventType.TOUCH_CANCEL, this.onTouchEnd, this);
input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
input.on(Input.EventType.KEY_UP, this.onKeyUp, this);
}
onDisable () {
input.off(Input.EventType.TOUCH_START, this.onTouchStart, this);
input.off(Input.EventType.TOUCH_MOVE, this.onTouchMove, this);
input.off(Input.EventType.TOUCH_END, this.onTouchEnd, this);
input.off(Input.EventType.TOUCH_CANCEL, this.onTouchEnd, this);
input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this);
input.off(Input.EventType.KEY_UP, this.onKeyUp, this);
}
get moveDir (): Vec2 { return this._moveDir; }
}
这里有两件事值得单独说。
第一,input.on 和 node.on 不是一回事。input 对象派发的是全局输入事件,跟场景树无关,谁注册都能收到;node.on 走的是场景图的捕获、目标、冒泡三阶段派发,只有触点落在节点区域内才会触发。我一开始用 node.on 监听整个 Canvas 层的拖拽,结果发现订单列表上的按钮一按,事件就被按钮拦截了,根本传不到我的输入层。后来我把输入统一挂到 input 上,UI 的点击用 node.on 单独处理,两边彻底分开。
第二,onDisable 里必须 off。这是 3.x 事件系统跟 2.x 最大的体感差异之一。2.x 时代的事件对象是全局单例,节点销毁了监听还在,你得自己记得清;3.x 官方文档明确推荐在 onEnable 注册、onDisable 解绑,配套的写法是第三参数传 this,这样 off 的参数能跟 on 一一对应上。
2.2 getUILocation 和 getLocation:我选错了整整一天
这是新手最容易踩的一个坑,我踩了整整一天。
EventTouch 上有两个看起来差不多的方法。getLocation() 返回的是触点在屏幕空间下的位置,也就是物理像素;getUILocation() 返回的是 UI 坐标系下的位置,也就是引擎适配之后的设计分辨率坐标。第三个 getLocationInView() 返回触点在游戏窗口中的位置。
我的设计分辨率是 720×1280,某台测试机的实际分辨率是 1080×2340。第一次我用了 getLocation(),拿这个坐标去算无人机该往哪飞,结果无人机在屏幕上飞的速度跟手指划动的速度对不上,划到底了无人机只挪了一半。因为 getLocation() 给的是物理像素,而节点位置活在 720×1280 的世界里,两者差着一个缩放倍率。
改用 getUILocation() 之后,手指划多少无人机就飞多少,比例对了。
import { _decorator, Component, EventTouch, Input, input, Vec2, Node, UITransform } from 'cc';
const { ccclass } = _decorator;
@ccclass('DragHandle')
export class DragHandle extends Component {
private _uiPos = new Vec2();
onTouchMove (event: EventTouch) {
// UI 坐标系的触点位置
event.getUILocation(this._uiPos);
// 换算到本节点的局部坐标(以锚点为原点)
const local = this.node.getComponent(UITransform)!.convertToNodeSpaceAR(this._uiPos);
this.node.setPosition(local.x, local.y, this.node.position.z);
}
}
convertToNodeSpaceAR 这个方法在 3.x 里是 UITransform 上的,作用是把一个世界坐标点转成某个 UI 节点的局部坐标,局部坐标以锚点为原点。2.x 里对应的是 node.convertToNodeSpaceAR 加 node.parent.convertToWorldSpaceAR 两次调用,3.x 把它收到了 UITransform 上,少写一半代码。
判断该用哪一套,我现在的口诀是:只要结果要喂给节点位置或者 UI 元素,就用 getUILocation;只有要做屏幕像素级的东西(比如截图坐标、原生 SDK 的坐标)才用 getLocation。
2.3 无人机为什么不用刚体速度驱动
第一版我给无人机挂了 RigidBody2D,直接写 linearVelocity 控制方向。手感很糟,怎么说呢,像是隔着一层橡胶在操作。
原因在于刚体速度是"物理量",它带惯性、带阻尼、带碰撞反馈。你手指快速划一下,无人机不会立刻到那个速度,得加速过去;松手了速度也不会立刻归零,得减速。玩家的直觉是"手指在哪,无人机去哪",中间这段物理过渡全是不必要的迟滞。
我改成用位置插值:手指的目标点算出来,无人机的位置每帧朝那个点逼近一小步。
import { _decorator, Component, Vec3, director } from 'cc';
const { ccclass } = _decorator;
@ccclass('DroneMover')
export class DroneMover extends Component {
private _target = new Vec3();
private _current = new Vec3();
private _follow = 12; // 每秒追上的比例
update () {
const dt = director.getDeltaTime();
// 用指数逼近代替固定 lerp,帧率变化时手感一致
const k = 1 - Math.exp(-this._follow * dt);
this._current.x += (this._target.x - this._current.x) * k;
this._current.y += (this._target.y - this._current.y) * k;
this.node.setPosition(this._current);
}
}
两种做法的适用场景其实很清晰:
| 场景 | 建议 | 原因 |
|---|---|---|
| 玩家直接操控的主角 | 位置插值 | 需要"手指即所愿",不能有惯性延迟 |
| 被击飞的道具、掉落物 | 刚体速度或冲量 | 需要真实碰撞和抛物线,玩家看的是结果不是操作 |
| 需要被障碍物推开的载具 | 刚体 | 碰撞后必须被正确弹开,插值做不到 |
| 风区里的无人机 | 两者叠加 | 基础位移用插值,风的偏移量用刚体或衰减计算 |
用插值的时候有个坑,我用 Math.exp 而不是固定的 lerp(t, 0.2),是因为固定的 lerp 系数在 120 帧和 30 帧的机器上速度完全不同。指数逼近把帧率差异吸收掉了。
2.4 多机型分辨率下,输入区域怎么对齐
我第一版把"拖拽区域"写死成一个矩形,坐标是 720×1280 里的某一小块。真机一测,在长屏手机上这块区域偏到了上方,底下够不着。
根因是我把触摸坐标当成了设计分辨率坐标,但引擎的适配策略是"适配屏幕宽度或高度",可见区域尺寸会随设备变化。
在 3.x 里拿这些数据的入口是 view 和 screen:
| 我要什么 | 用什么 | 说明 |
|---|---|---|
| 设计分辨率(我定的 720×1280) | view.getDesignResolutionSize() |
固定值 |
| 当前实际可见区域尺寸 | view.getVisibleSize() |
随适配策略变化 |
| 画布缩放比 | view.getScaleX() / getScaleY() |
物理像素到设计分辨率的换算 |
| 设备像素比 | screen.devicePixelRatio |
物理像素与 CSS 像素之比 |
我的做法是不再写死矩形,而是把拖拽区挂在 UI 节点上,让 Widget 组件去对齐。UI 层的节点挂 Widget、设好对齐边距,拖拽区就自动跟着屏幕走。这样任何机型都不用改代码。
真正需要代码算的,只有"把世界坐标的关卡区域映射到 UI 坐标"这一件事。比如玩家把无人机拖到屏幕边缘外侧很远的地方,我需要把它夹回关卡边界。这个用 view.getVisibleSize() 拿到可见区域,再和关卡的世界坐标范围做一次夹取就够了。
2.5 边滑边松手,和输入延迟怎么查
"边滑边松手"指的是玩家手指按下之后一路划出去,在松手之前手指离开了屏幕范围,或者划到了别的 UI 上。
3.x 对这种情况有专门的事件:Input.EventType.TOUCH_CANCEL。它的触发条件是触点在目标节点区域外离开屏幕时。也就是说,触摸开始时落在了我的拖拽区里,滑出去松手,走的是 TOUCH_END;开始就不在区域里(或者被别的节点截走),走的是 TOUCH_CANCEL。
我第一版只监听了 TOUCH_END,结果玩家从按钮上开始拖动(按钮把事件吞了),再滑到我的拖拽区松手,我的代码完全收不到任何结束信号,无人机就永远跟着手指飞。
修法很简单:TOUCH_CANCEL 和 TOUCH_END 走同一个处理函数,把拖拽状态清掉。我上面那段代码里就是这么写的。
多指的情况我也做了处理。EventTouch.getID() 能拿到触点标识,用来跟踪具体哪根手指。getTouches() 返回有变动的触点列表,getAllTouches() 返回全部触点。我现在的策略是只认第一根按下的手指做拖拽,后续手指的移动直接忽略。多指同时操作在这个游戏里没有意义,做了反而容易误触。
至于输入延迟,我踩过一次假象:觉得手指到无人机之间有延迟,其实是掉帧。60 帧下一帧是 16.7 毫秒,掉到 30 帧就是 33 毫秒,人眼能感觉到。
我的排查路径是这样的:
- 先看帧率,确认是不是掉帧导致的。在 Creator 的调试器里看 FPS 曲线,用
profiler.showStats()也能在运行时把统计面板显示出来。 - 如果帧率是稳的,那才是输入处理慢。检查回调里有没有做重活。我见过有人在
TOUCH_MOVE里做射线检测查障碍,每次手指移动都查一遍几十个碰撞体。 - 再看事件本身有没有积压。如果
TOUCH_MOVE的回调在真机上每秒触发 120 次而帧率是 30,说明事件队列在堆积,这时候应该做输入采样而不是每次事件都处理。
还有一个我自己踩过的坑:为了让无人机跟手,我在 update 里读输入状态,但输入状态是在事件回调里更新的。事件回调的时机和 update 的时机不在同一个位置,顺序一乱就出现"手指已经抬起,无人机还往前飞一帧"。我的处理是把"抬起了"这个状态也放进意图对象里,让 update 统一判断,而不是在回调里直接改位置。
