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;
}
代入几组数字就很清楚:
jumpVelocity | gravity | 峰高(像素) | 滞空(秒) | 评价 |
|---|---|---|---|---|
| 900 | 1800 | 225 | 1.00 | 峰高太高,滞空太长,像月球 |
| 700 | 2400 | 102 | 0.58 | 偏低偏快,砖块间距被压死 |
| 900 | 3000 | 135 | 0.60 | 推荐起点,跳得开又不飘 |
| 1100 | 3000 | 202 | 0.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 关卡编辑与配置表 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。