7.1 输入与角色控制
第 6 章收尾时,《方块冲刺》已经能弹出设置面板、能滚动关卡列表,但主玩法还是空的:玩家方块只会站在原地。从这一节开始进入卷二的两章玩法内容。本章先把「核心循环」跑通——自动向右跑、点击或空格跳、撞到东西就死;下一章再把关卡做成数据驱动。
与站内
game/client/那些引擎工程文的分工:它们讲「角色控制器有哪几种方案」;本节给的是能过tsc --noEmit的 Cocos Creator 3.8 组件脚本,以及一份参数表,说明每个数字该填多少、为什么。
7.1.1 先定核心循环
《方块冲刺》的核心循环只有四个动作,先把它写成一张表,后面所有代码都服务于它:
| 动作 | 触发 | 结果 |
|---|---|---|
| 自动跑 | 每帧 | 世界向左滚,玩家在屏幕上的 x 不变 |
| 跳跃 | 空格 / ↑ / 点击屏幕 | 给刚体一个向上的初速度 |
| 落地 | 与地面发生接触 | 允许下一次跳跃 |
| 死亡 | 碰到危险碰撞体 | 重开本关,不结算金币 |
这里有一个贯穿卷二的重要决定:玩家节点在世界坐标里原地不动,是「关卡」在动。好处是摄像机跟随、进度计算、对象回收都变成对同一个 levelRoot 节点的平移,不需要每帧回写玩家位置。代价也很直接:物理系统里「玩家在动」与「世界在动」的接触判定并不完全等价,这一点 7.2 会展开。
7.1.2 把三种设备归一成「跳意」
键盘、鼠标、触摸屏的 API 完全不同,但玩法只关心一件事:这一帧有没有人想跳。所以第一步不是接事件,而是先定义意图:
// assets/scripts/core/input-intent.ts
export interface InputIntent {
/** 本帧是否有一次新的「按下跳跃」——边沿触发,读一次就清掉。 */
consumeJump(): boolean;
/** 跳跃键当前是否被按住——用于可变高度跳跃。 */
isHolding(): boolean;
}
这个接口放在 assets/scripts/core/ 下,不 import 任何 cc,所以它能在 Node 里被单测(第 16 章会接进去)。Cocos 侧的组件只负责把引擎事件翻译成这两个方法:
// assets/scripts/game/input-bridge.ts
import { _decorator, Component, input, Input, EventKeyboard, EventTouch, KeyCode } from 'cc';
const { ccclass } = _decorator;
@ccclass('InputBridge')
export class InputBridge extends Component {
private jumpQueued = false;
private holdJump = false;
onEnable(): void {
input.on(Input.EventType.KEY_DOWN, this.onKeyDown, this);
input.on(Input.EventType.KEY_UP, this.onKeyUp, this);
input.on(Input.EventType.TOUCH_START, this.onTouchStart, this);
input.on(Input.EventType.TOUCH_END, this.onTouchEnd, this);
}
onDisable(): void {
input.off(Input.EventType.KEY_DOWN, this.onKeyDown, this);
input.off(Input.EventType.KEY_UP, this.onKeyUp, this);
input.off(Input.EventType.TOUCH_START, this.onTouchStart, this);
input.off(Input.EventType.TOUCH_END, this.onTouchEnd, this);
}
private onKeyDown(e: EventKeyboard): void {
if (e.keyCode === KeyCode.SPACE || e.keyCode === KeyCode.ARROW_UP) {
this.jumpQueued = true;
this.holdJump = true;
}
}
private onKeyUp(e: EventKeyboard): void {
if (e.keyCode === KeyCode.SPACE || e.keyCode === KeyCode.ARROW_UP) {
this.holdJump = false;
}
}
private onTouchStart(_e: EventTouch): void { this.jumpQueued = true; this.holdJump = true; }
private onTouchEnd(_e: EventTouch): void { this.holdJump = false; }
consumeJump(): boolean {
const q = this.jumpQueued;
this.jumpQueued = false;
return q;
}
isHolding(): boolean { return this.holdJump; }
}
三个细节值得说明。第一,用全局单例 input 而不是 this.node.on(...):触摸事件挂在节点上时,需要节点有 UITransform 且被射线命中,而我们要的是「屏幕任意位置点击都能跳」;input 收到的是整个视图的事件,正合适。
第二,KEY_DOWN 与 KEY_UP 分开注册。Cocos 对键盘没有稳定的「持续按住」事件,所以「按住」状态要自己用 down/up 两个事件维护。判平台别想当然——桌面浏览器有键盘、手机浏览器没有,但触摸与键盘可以同时生效,这里不做平台判断反而是对的。
第三,onEnable / onDisable 成对注册与注销。用 onLoad / onDestroy 也能跑,但常驻节点被切场景复用时会漏掉 off,导致重复回调——第 5 章讲常驻节点时已经踩过一次,这里直接用生命周期对称的两个钩子。
7.1.3 角色控制:RigidBody2D 与线性速度
跳跃本身很简单:给刚体一个向上的速度,剩下的交给重力。Cocos 3.8 的 2D 物理里,这个动作就是给 RigidBody2D.linearVelocity 赋值:
// assets/scripts/game/player-controller.ts
import {
_decorator, Component, Collider2D, Contact2DType, IPhysics2DContact,
RigidBody2D, Vec2, ERigidBody2DType,
} from 'cc';
const { ccclass, property } = _decorator;
@ccclass('PlayerController')
export class PlayerController extends Component {
@property({ range: [400, 1600], slide: true, tooltip: '起跳初速度(像素/秒)' })
jumpVelocity = 900;
@property({ range: [0.05, 0.95], slide: true, step: 0.05, tooltip: '松手时的速度保留比例' })
jumpCut = 0.45;
@property({ type: RigidBody2D })
body: RigidBody2D | null = null;
private grounded = false;
onLoad(): void {
const col = this.getComponent(Collider2D);
if (col) {
col.on(Contact2DType.BEGIN_CONTACT, this.onBegin, this);
col.on(Contact2DType.END_CONTACT, this.onEnd, this);
}
if (this.body) {
this.body.type = ERigidBody2DType.Dynamic;
this.body.fixedRotation = true;
}
}
private onBegin(_self: Collider2D, _other: Collider2D, _contact: IPhysics2DContact | null): void {
this.grounded = true;
}
private onEnd(): void { this.grounded = false; }
tryJump(): boolean {
if (!this.grounded || !this.body) return false;
this.body.linearVelocity = new Vec2(0, this.jumpVelocity);
this.grounded = false;
return true;
}
cutJump(): void {
if (!this.body) return;
const v = this.body.linearVelocity;
if (v.y > 0) this.body.linearVelocity = new Vec2(v.x, v.y * this.jumpCut);
}
}
Contact2DType.BEGIN_CONTACT 的回调签名是 (selfCollider, otherCollider, contact)——Cocos 传给回调的是两个 Collider2D 与一个接触对象,而接触对象 IPhysics2DContact 上的字段是 colliderA / colliderB,不是很多人以为的 selfCollider / otherCollider。写错属性名 tsc 会直接报 TS2339,这是查 cc.d.ts 才能确认的一类坑。
grounded 用「接触开始/结束」维护,而不是向下打一条射线,是因为地面往往由多个碰撞体拼成,射线只探一个点会漏。代价是站在两块砖的接缝上时,END_CONTACT 可能先于下一块砖的 BEGIN_CONTACT 触发,产生一帧假落地——7.2 会用接触计数 contactCount 修掉它。
7.1.4 手感三件套
上面这段代码能跳,但手感很差。三个数字决定了「好不好玩」,而且它们与引擎无关,值得单独抽成纯逻辑:
// assets/scripts/core/jump-gate.ts
export interface JumpGateConfig {
/** 离地后仍允许起跳的宽限时间(秒),俗称土狼时间。 */
coyoteTime: number;
/** 落地之前提前按下的缓存时间(秒)。 */
bufferTime: number;
}
export class JumpGate {
private airborneFor = 0;
private bufferedFor = Number.POSITIVE_INFINITY;
private readonly coyoteTime: number;
private readonly bufferTime: number;
constructor(cfg: JumpGateConfig) {
this.coyoteTime = cfg.coyoteTime;
this.bufferTime = cfg.bufferTime;
}
/** 每帧调用一次;grounded 表示本帧是否踩在地上。 */
step(dt: number, grounded: boolean): void {
this.airborneFor = grounded ? 0 : this.airborneFor + dt;
this.bufferedFor += dt;
}
/** 玩家按下跳跃键时调用。 */
pressJump(): void { this.bufferedFor = 0; }
/** 本帧是否应该真正起跳;返回 true 表示这次缓冲已被消费。 */
tryConsume(): boolean {
const canJump = this.airborneFor <= this.coyoteTime;
const wants = this.bufferedFor <= this.bufferTime;
if (!canJump || !wants) return false;
this.bufferedFor = Number.POSITIVE_INFINITY;
this.airborneFor = Number.POSITIVE_INFINITY;
return true;
}
}
土狼时间解决「刚跑出平台边缘 50 毫秒内还想跳」,跳跃缓冲解决「落地前一瞬间就按了跳,落地却不动」。两者都只在几十毫秒的量级,肉眼看不出,但缺了它们玩家会明确说「这游戏手感黏」。注意构造函数没有用参数属性写法——本套书限 erasable syntax,constructor(private readonly cfg) 这种简写会被 Node 的类型剥离拒绝。
7.1.5 在编辑器里搭出 Player.prefab
上面两个组件要在编辑器里挂到节点上。这一小节属于 L3:需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 可照做的步骤如下:
- 层级管理器右键 → 创建 → 2D 对象 → Sprite,重命名为
Player。 - 选中
Player,属性面板Sprite组件里把assets/textures/player.png拖到SpriteFrame;Size Mode设为Custom,Trim取消勾选。 - 点属性面板底部「添加组件」→ Physics2D →
BoxCollider2D。设置Size为40 × 60、Offset为(0, 0)、Sensor不勾、Group选PLAYER。 - 再加
RigidBody2D:Type选Dynamic,勾选Fixed Rotation,Gravity Scale填1,Linear Damping与Angular Damping都填0。 - 添加自定义脚本
PlayerController,把刚加的RigidBody2D组件拖进body槽;Jump Velocity填900,Jump Cut填0.45。 - 在
Canvas下新建空节点LevelRoot,把Player拖进LevelRoot成为子节点。 - 把
Player从层级管理器拖到assets/prefabs/目录,生成Player.prefab。
需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 打开这个 .prefab 你会看到:根节点名 Player,_components 数组里依次是 cc.UITransform、cc.BoxCollider2D、cc.RigidBody2D 与脚本组件 PlayerController,脚本组件上带有 jumpVelocity、jumpCut 两个可调数字:
[
{ "__type__": "cc.Prefab", "_name": "Player", "data": { "__id__": 1 } },
{
"__type__": "cc.Node",
"_name": "Player",
"_parent": null,
"_children": [],
"_components": [ { "__id__": 2 }, { "__id__": 3 }, { "__id__": 4 }, { "__id__": 5 } ],
"_lpos": { "__type__": "cc.Vec3", "x": 0, "y": 0, "z": 0 },
"_layer": 33554432
},
{
"__type__": "cc.UITransform",
"node": { "__id__": 1 },
"_contentSize": { "__type__": "cc.Size", "width": 48, "height": 64 },
"_anchorPoint": { "__type__": "cc.Vec2", "x": 0.5, "y": 0.5 }
},
{
"__type__": "cc.BoxCollider2D",
"node": { "__id__": 1 },
"size": { "__type__": "cc.Size", "width": 40, "height": 60 },
"offset": { "__type__": "cc.Vec2", "x": 0, "y": 0 },
"sensor": false,
"group": 1
},
{
"__type__": "cc.RigidBody2D",
"node": { "__id__": 1 },
"_type": 2,
"fixedRotation": true,
"gravityScale": 1
},
{
"__type__": "PlayerController",
"node": { "__id__": 1 },
"jumpVelocity": 900,
"jumpCut": 0.45
}
]
_layer 的 33554432 是 UI_2D 层(1 << 25),精灵节点必须在 UI 层才会被 2D 摄像机渲染;BoxCollider2D 的 size 是全宽全高,与卷一 mini2d 的 half 语义相反,这是从自研运行时切到引擎时最容易算错的一处。
7.1.6 参数一览与调参顺序
先把数值定下来,7.3 会做成一页可热调的配置:
| 参数 | 起始值 | 单位 | 调大的后果 |
|---|---|---|---|
jumpVelocity | 900 | 像素/秒 | 跳得更高、滞空更久 |
jumpCut | 0.45 | 比例 | 轻点跳得更低,长按更高 |
coyoteTime | 0.10 | 秒 | 边缘更容易跳,过大像二段跳 |
bufferTime | 0.12 | 秒 | 连按更容易接上,过大像多跳一次 |
重力 gravity.y | -1800 | 像素/秒² | 下落更快、整体更「脆」 |
调参顺序建议是:先定重力与 jumpVelocity(决定跳跃高度与滞空),再调 jumpCut(决定轻点与长按的差异),最后加土狼时间与缓冲(决定容错)。顺序反了会一直在互相打架——把跳跃高度的问题拿去调缓冲时间,只会越调越糊。
7.1.7 四个常见坑
第一,把触摸事件挂在没有 UITransform 的节点上。 this.node.on(Node.EventType.TOUCH_START, ...) 依赖节点参与 UI 命中测试;纯逻辑节点没有 UITransform,事件永远不触发。全屏点击一律用 input。
第二,用字符串比较键码。 KeyCode 是数字枚举,写 e.keyCode === 'Space' 永远为假。要用 KeyCode.SPACE、KeyCode.ARROW_UP。
第三,每帧给 linearVelocity 赋值。 如果 update 里每帧都写 linearVelocity = new Vec2(0, jumpVelocity),重力就再也拉不下来。只在起跳那一帧赋值。
第四,忘了 fixedRotation。 动态刚体撞到斜边会旋转,方块滚起来像骰子。在编辑器里勾上 Fixed Rotation,或在 onLoad 里设 this.body.fixedRotation = true。
下一节处理「撞到了什么」:把碰撞体分组、给金币与尖刺写各自的接触回调,并让关卡在玩家越过 goalX 时结算推进。
阅读导航:上一节:目录 · 下一节:7.2 碰撞与关卡推进 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。