《Cocos 游戏开发实战》7.3 手感调优与参数化

玩法跑通之后,真正决定留存的是一堆散落的数字。本节把《方块冲刺》的手感参数抽成一份 FeelConfig,用 @property 的 range 与 slide 暴露成可拖动的滑条,讲清 jumpVelocity 与 gravity 为什么必须成对调,再给出运行时热调、本地持久化与 feel.json 导入设置的做法。

7.3 手感调优与参数化

7.1 与 7.2 把玩法跑通了:会跳、会死、会通关。但把 speed = 320、jumpVelocity = 900 这些数字写死在各个组件的属性面板上,会让每一次调参都变成一次「点开节点 → 找组件 → 改数字 → 重启预览」的循环。一关改十次、十关改一百次,人会在第三次就想放弃。

这一节把散落的数字收成一份数据,并让它们能被拖动、能热改、能存盘。

7.3.1 手感其实是四个可测量的维度

「手感好」听起来玄,拆开其实只有四件事,每件都有对应的数字:

维度玩家感受对应参数常见区间
响应延迟按下去多久才动输入采样时机、缓冲时间0–2 帧
跳跃弧线跳多高、滞空多久jumpVelocity、gravity峰高 110–160 像素
落地反馈踩地有没有「实感」接触计数、顿帧、音效2–5 帧停顿
镜头语言视野是否跟得上前视量、跟随阻尼前视 80–160 像素

先定这四个数字,再谈美术与特效。反过来做的团队,往往会用一堆粒子去掩盖一个「跳跃滞空 0.9 秒」的物理错误——手感是数字问题,不是特效问题。

7.3.2 把参数抽成一份 FeelConfig

第一步是定义结构。和前面几节一样,这部分不依赖引擎,可以直接单测:

// assets/scripts/core/feel-config.ts
export interface FeelConfig {
  /** 起跳初速度,像素/秒。 */
  jumpVelocity: number;
  /** 松手时的速度保留比例,0–1。 */
  jumpCut: number;
  /** 土狼时间,秒。 */
  coyoteTime: number;
  /** 跳跃缓冲时间,秒。 */
  bufferTime: number;
  /** 关卡滚动速度,像素/秒。 */
  runSpeed: number;
  /** 摄像机前视量,像素。 */
  lookAhead: number;
  /** 重力加速度的绝对值,像素/秒²。 */
  gravity: number;
}

export const DEFAULT_FEEL: FeelConfig = {
  jumpVelocity: 900,
  jumpCut: 0.45,
  coyoteTime: 0.10,
  bufferTime: 0.12,
  runSpeed: 320,
  lookAhead: 120,
  gravity: 1800,
};

/** 用远端配置覆盖默认值,未提供的字段保持原样。 */
export function mergeFeel(base: FeelConfig, patch: Partial<FeelConfig>): FeelConfig {
  return { ...base, ...patch };
}

gravity 在这里存的是绝对值(1800),符号在写进物理系统时才加负号。这样做的理由是:策划看数值时不会把「负重力」当成「往下的力」,而 -1800 与 1800 混在一起最容易漏写负号,导致方块往上飞。约定「配置里全是正数,符号在代码里补」能消掉一整类 bug。

7.3.3 用 @property 把参数变成滑条

接下来把这份配置暴露到属性面板。Cocos 的 @property 支持 range 与 slide,把数字变成带范围的滑条:

// assets/scripts/game/feel-tuner.ts
import { _decorator, Component, JsonAsset, resources, sys } from 'cc';
const { ccclass, property, menu, executeInEditMode } = _decorator;

@ccclass('FeelTuner')
@menu('block-dash/FeelTuner')
@executeInEditMode
export class FeelTuner 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({ range: [0, 0.3], slide: true, step: 0.01, tooltip: '土狼时间(秒)' })
  coyoteTime = 0.10;

  @property({ range: [0, 0.3], slide: true, step: 0.01, tooltip: '跳跃缓冲(秒)' })
  bufferTime = 0.12;

  @property({ range: [200, 900], slide: true, tooltip: '关卡滚动速度(像素/秒)' })
  runSpeed = 320;

  @property({ range: [0, 320], slide: true, tooltip: '摄像机前视量(像素)' })
  lookAhead = 120;

  /** 从 configs/feel.json 覆盖当前面板值,方便策划直接改文件。 */
  loadFromJson(): void {
    resources.load('configs/feel', JsonAsset, (err: Error | null, asset: JsonAsset) => {
      if (err || !asset.json) return;
      const data = asset.json as Record<string, number>;
      this.jumpVelocity = data.jumpVelocity ?? this.jumpVelocity;
      this.jumpCut = data.jumpCut ?? this.jumpCut;
      this.runSpeed = data.runSpeed ?? this.runSpeed;
    });
  }

  /** 把当前面板值写进本地存档,下次启动接着用。 */
  saveToLocal(): void {
    sys.localStorage.setItem('bd.feel', JSON.stringify({
      jumpVelocity: this.jumpVelocity,
      jumpCut: this.jumpCut,
      runSpeed: this.runSpeed,
    }));
  }
}

@menu('block-dash/FeelTuner') 把组件收进「添加组件」菜单的自定义分组,不用每次在长长的列表里翻。@executeInEditMode 让 update 与生命周期钩子在编辑器预览态也执行,适合做「拖动滑条立即看到跳跃高度变化」这类即时反馈;代价是编辑态也在跑代码,写 @executeInEditMode 的组件要确保不依赖运行时才存在的对象。

step 与 range 一起用时要留意精度:coyoteTime 的 step 是 0.01,range 上限 0.3,滑条一共 30 档,够用;如果把 step 写成 0.1,滑条只剩 3 档,就调不出「0.08 与 0.12 的区别」——而这个区别恰恰是手感的关键。

7.3.4 为什么 jumpVelocity 与 gravity 必须成对调

跳跃的高度与滞空时间只由两个量决定,写出来就能看出它们为什么绑在一起:

// assets/scripts/core/jump-arc.ts
/** 已知初速度与重力(都取正数),求跳跃峰高。 */
export function apexHeight(v0: number, gravity: number): number {
  return (v0 * v0) / (2 * gravity);
}

/** 求完整滞空时间(起跳到落回同高度)。 */
export function airTime(v0: number, gravity: number): number {
  return (2 * v0) / gravity;
}

代入几组数字就很清楚:

jumpVelocitygravity峰高(像素)滞空(秒)评价
90018002251.00峰高太高,滞空太长,像月球
70024001020.58偏低偏快,砖块间距被压死
90030001350.60推荐起点,跳得开又不飘
110030002020.73大跳,留给需要跨宽沟的关卡

关键结论:只改 jumpVelocity 会同时改峰高与滞空,而这两个量在关卡设计里是分开约束的——沟宽决定峰高下限,尖刺间距决定滞空上限。所以调参的正确姿势是「先定 gravity(决定整体节奏),再用 jumpVelocity 微调峰高」。7.3.1 那张表里写「峰高 110–160 像素」,就是因为 960×540 的分辨率下,玩家方块高 64 像素,跳跃高度约 2 倍身高最舒服。

还有一个容易忽略的约束:峰高必须让玩家看得见前方。如果峰高只有 90 像素,玩家跳起来视野几乎没变,前方两屏的障碍是「突然出现」的;峰高到 135 像素时,跳跃的瞬间能多看到约 1/3 屏,玩家才有「提前预判」的余地。手感与可读性在这里是同一件事。

7.3.5 摄像机前视量:镜头为什么要领先玩家

7.3.1 那张表的最后一行是「镜头语言」,对应 lookAhead。自动跑游戏里镜头不能死盯着玩家——玩家永远在屏幕左侧三分之一处,前方才是信息密集区:

// assets/scripts/game/camera-follow.ts
import { _decorator, Component, Node, math } from 'cc';
const { ccclass, property } = _decorator;

@ccclass('CameraFollow')
export class CameraFollow extends Component {
  @property({ type: Node, tooltip: '要跟随的目标节点' })
  target: Node | null = null;

  @property({ range: [0, 320], slide: true, tooltip: '前视量(像素)' })
  lookAhead = 120;

  @property({ range: [1, 20], slide: true, tooltip: '跟随阻尼,越大越跟手' })
  damping = 8;

  lateUpdate(dt: number): void {
    if (!this.target) return;
    const t = math.clamp01(this.damping * dt);
    const cur = this.node.position;
    const tx = this.target.position.x + this.lookAhead;
    const ty = this.target.position.y;
    this.node.setPosition(cur.x + (tx - cur.x) * t, cur.y + (ty - cur.y) * t, 0);
  }
}

两个细节。第一,用 lateUpdate 而不是 update:摄像机要在本帧所有移动结束之后再跟,否则会跟到玩家上一帧的位置,产生「镜头抖一下」的滞后感。第二,damping 是阻尼而不是硬跟随:硬跟随(t = 1)会让镜头随玩家的每一次跳跃上下猛甩,看着晕;阻尼跟随把垂直方向的高频抖动滤掉,代价是落地时镜头会「追一下」。经验值是水平方向 6–10、垂直方向 4–6。

前视量与滚动速度要一起定:lookAhead 太小,玩家看不到前方;太大,玩家会觉得「镜头抢在前面」。320 像素/秒的滚动速度配 120 像素前视量,大致是「提前 0.375 秒看到」——刚好够反应,不会觉得镜头失控。

7.3.6 热调:不重开预览改数值

预览态调参最烦的是每次改完都要重启。两个手段能让它接近实时:

第一,用 sys.localStorage 存盘。上面 saveToLocal() 已经把面板值写进本地存储;onLoad 里读回来,重启预览时自动恢复上次的调参结果,不用重新拖滑条。

第二,把配置放进 assets/configs/feel.json,让策划改文件而不是改场景。文件内容就是一份纯数据:

{
  "jumpVelocity": 900,
  "jumpCut": 0.45,
  "coyoteTime": 0.10,
  "bufferTime": 0.12,
  "runSpeed": 320,
  "lookAhead": 120,
  "gravity": 3000
}

这一小节属于 L3:需 Cocos Creator 3.8 编辑器打开,本节不可命令行复现。 编辑器为这个 JSON 生成的 assets/configs/feel.json.meta 长这样,importer 是 json,说明它会被导入成 JsonAsset:

{
  "ver": "1.1.0",
  "importer": "json",
  "imported": true,
  "uuid": "3c1a0f6e-8b2d-4a71-9f30-5d2c7b4e1a88",
  "files": [ ".json" ],
  "subMetas": {},
  "userData": {}
}

在编辑器里打开会看到:资源管理器中 feel.json 的图标是一个「{}」文档图标;点开它,属性面板只显示 Asset Bundle 归属等信息,没有字段级的编辑界面——JSON 的内容要在外部编辑器里改。改完后切回 Cocos Creator,编辑器会自动重新导入(imported 从 true 变回 false 再变 true),resources.load 下次读到的就是新值。uuid 是编辑器生成的,不要手改,它被所有引用这个资源的节点记录着,改了会导致引用失效。

注意 resources.load 读的是 assets/resources/ 下的资源。要读 assets/configs/ 里的文件,要么把目录配成 Bundle,要么把文件放到 assets/resources/。本项目的做法是 8.1 要讲的:把 assets/configs/ 配成 Bundle bundle-levels,用 assetManager.loadBundle 加载。7.3 先记住这条路径规则,不要以为任意目录都能被 resources 读到。

7.3.7 三个调试习惯

第一,把参数表贴在手边。 7.3.4 那张表要打印出来贴显示器边上。调参时每改一个数字,先想它同时改了哪个量,比盲调快得多。

第二,一次只改一个维度。 同时动 jumpVelocity 与 gravity 会让你分不清是哪一个在起作用。改完记一笔:v0=900, g=3000 → 峰高 135,下次就有参照。

第三,用碰撞体线框复核。 手感的错觉常来自碰撞盒与视觉精灵不重合——精灵看起来落地了,碰撞盒还差 6 像素。7.2 的 debugDrawFlags 打开后一眼能看出偏差,比对着数字猜快。

7.3.8 收口:这一章交出了什么

到这里,玩法一(核心循环与手感)完整了:输入归一、角色控制、碰撞规则、进度推进、参数化。工程里多了 core/feel-config.ts、core/jump-arc.ts、game/feel-tuner.ts,以及一份 assets/configs/feel.json。手感参数从「散落在属性面板」变成了「一份可读可写的数据」。

但还剩一个问题:关卡本身还是场景里手工摆出来的——Ground、Coins、Hazards 都是一个个节点。加一关就要复制一份场景,改一处沟宽就要挪三个节点。下一章把整关的摆放也变成数据:用一份 levels.json 描述障碍、金币与终点,由代码在运行时把场景搭出来。

阅读导航:上一节:7.2 碰撞与关卡推进 · 下一节:8.1 关卡编辑与配置表 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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