《Cocos 游戏开发实战》7.1 输入与角色控制

本节把《方块冲刺》的输入与角色控制落到 Cocos Creator 3.8:先用 input 与 Input.EventType 把键盘空格、方向键与触摸归一成跳示意向,再用 RigidBody2D 写自动跑与可变高度跳跃,最后补齐土狼时间、跳跃缓冲、松手切跳三件套手感,并给出 Player.prefab 的编辑器搭建步骤与组件面板取值。

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 编辑器打开,本节不可命令行复现。 可照做的步骤如下:

  1. 层级管理器右键 → 创建 → 2D 对象 → Sprite,重命名为 Player。
  2. 选中 Player,属性面板 Sprite 组件里把 assets/textures/player.png 拖到 SpriteFrame;Size Mode 设为 Custom,Trim 取消勾选。
  3. 点属性面板底部「添加组件」→ Physics2D → BoxCollider2D。设置 Size 为 40 × 60、Offset 为 (0, 0)、Sensor 不勾、Group 选 PLAYER。
  4. 再加 RigidBody2D:Type 选 Dynamic,勾选 Fixed Rotation,Gravity Scale 填 1,Linear Damping 与 Angular Damping 都填 0。
  5. 添加自定义脚本 PlayerController,把刚加的 RigidBody2D 组件拖进 body 槽;Jump Velocity 填 900,Jump Cut 填 0.45。
  6. 在 Canvas 下新建空节点 LevelRoot,把 Player 拖进 LevelRoot 成为子节点。
  7. 把 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 会做成一页可热调的配置:

参数起始值单位调大的后果
jumpVelocity900像素/秒跳得更高、滞空更久
jumpCut0.45比例轻点跳得更低,长按更高
coyoteTime0.10秒边缘更容易跳,过大像二段跳
bufferTime0.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 碰撞与关卡推进 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画