让游戏可调:把数值从代码里抠出来
这本书从第一章开始就在说同一件事:好玩是调出来的,不是设计出来的。那么真正决定你游戏质量上限的,其实是一个很朴素的工程指标 —— 从「改一个数字」到「感受到结果」,要花多少秒?如果是两分钟,你一天调五十次;如果是两秒,你一天调三千次。这个差距,就是最终手感的差距。
先感受一下
下面这个 demo 里,那段 JSON 不是装饰。你改一个数字点「应用」,下面那个角色的物理立刻就变了 —— 它真的在解析那段配置。
试试把 gravity 改成 600,或者点一下「月球重力」。
游戏的行为由数据决定,而不是由代码决定。改行为不需要改代码,也就不需要重新编译、不需要重启、不需要你是个程序员。
听起来像是「工程整洁度」的问题。但它其实是一个设计问题 —— 因为它直接决定了你一天能做多少次设计决定。
迭代速度是一个设计指标
算一笔账。假设你在调跳跃手感:
| 硬编码 | @export 变量 | 热重载配置 | |
|---|---|---|---|
| 改一次的流程 | 改代码 → 保存 → 重新运行 → 跑到测试点 | 停下 → 在检视器里拖 → 再跑 | 游戏跑着的时候直接拖 |
| 耗时 | 30–60 秒 | 10–15 秒 | 0 秒 |
| 一小时能试 | 约 60 次 | 约 250 次 | 上千次 |
| 你会怎么做 | 「差不多就行了」 | 调到还不错 | 调到真的对 |
最后一行才是重点。迭代成本高的时候,人会不自觉地降低标准 —— 不是因为懒,是因为每一次尝试都很贵,你会在「还行」的时候停下。
任何你会调超过 20 次的东西,都值得为它做一个更快的调整方式。
哪些东西你会调超过 20 次?几乎是卷 III 和卷 IV 的全部内容:
- 手感的十二个参数(第 9 章)
- 宽容窗口的时长(第 10 章)
- 反馈的六层强度和时序(第 11 章)
- 摄像机的死区和前瞻(第 13 章)
- 所有的数值和曲线(第 14 章)
- 关卡的强度序列(第 16 章)
三个层次,成本递增
层次一:@export(十分钟,覆盖 80% 需求)
最低成本的做法,而且 Godot 原生支持。把常量改成导出变量:
extends CharacterBody2D
@export_group("基础物理")
@export var max_run: float = 112.0
@export var jump_speed: float = 340.0
@export var gravity: float = 1500.0
@export_group("宽容窗口")
@export_range(0.0, 0.3, 0.01) var coyote_time: float = 0.10
@export_range(0.0, 0.3, 0.01) var buffer_time: float = 0.12
@export_group("跳跃曲线")
@export_range(0.1, 1.0, 0.05) var cut_mult: float = 0.45
@export_range(1.0, 3.0, 0.05) var fall_mult: float = 1.55
- 用
@export_range而不是裸的@export。它给你一个滑块,而滑块能拖 —— 拖比输入数字快得多,而且能让你「扫过」一整段范围找感觉。 - 用
@export_group分组。二十个参数平铺在检视器里是找不到东西的。 - 最重要的:在游戏运行时,Godot 编辑器的「远程」标签页里可以直接改运行中场景的属性。点编辑器左上角的 Remote 切换,找到你的角色节点,拖滑块 —— 游戏跑着的时候手感就变了。这一条几乎把 @export 变成了热重载,而知道的人意外地少。
层次二:Resource 文件(半小时)
当参数需要被多个对象共享、或者需要多套配置切换时,升级到 Resource。
class_name MovementProfile extends Resource @export var max_run: float = 112.0 @export var jump_speed: float = 340.0 @export var gravity: float = 1500.0 @export_range(0.0, 0.3, 0.01) var coyote_time: float = 0.10 @export_range(0.0, 0.3, 0.01) var buffer_time: float = 0.12 @export_range(0.1, 1.0, 0.05) var cut_mult: float = 0.45 @export_range(1.0, 3.0, 0.05) var fall_mult: float = 1.55
extends CharacterBody2D @export var profile: MovementProfile func _physics_process(delta: float) -> void: var g := profile.gravity if velocity.y > 0.0: g *= profile.fall_mult velocity.y += g * delta # ……
好处:
- 你可以存好几个
.tres文件:normal.tres、underwater.tres、ice.tres,运行时切换就能做出水下 / 冰面的不同手感 - 不同的角色可以共享或各用一套
- 它是纯数据文件,能被 git diff 看懂(比场景文件友好得多)
一旦手感变成了可切换的数据,一些玩法就变得几乎免费:
- 冰面地形 → 换一个摩擦力极低的 profile
- 水下 → 换一个重力小、阻力大的 profile
- 吃了道具变身 → 换一个跳得更高的 profile
- 低重力关卡 → 换一个 profile
这些原本需要写一堆特殊逻辑的东西,现在都是「换一个资源引用」。数据驱动的收益经常是这样的 —— 它做着做着就变成了设计能力。
层次三:外部文件 + 热重载(一两小时)
最高一档:把配置放在游戏之外的 JSON / CSV 文件里,游戏运行时监视文件变化,一改就重新加载。
extends Node # autoload: Tuning
const PATH := "res://config/tuning.json"
var data: Dictionary = {}
var _last_mtime: int = 0
func _ready() -> void:
_load()
func _process(_delta: float) -> void:
# 只在编辑器 / 调试构建里做文件监视,发布版不需要
if not OS.is_debug_build():
return
var mtime := FileAccess.get_modified_time(PATH)
if mtime != _last_mtime:
_load()
reloaded.emit()
signal reloaded
func _load() -> void:
_last_mtime = FileAccess.get_modified_time(PATH)
var f := FileAccess.open(PATH, FileAccess.READ)
if f == null:
push_error("读不到配置:%s" % PATH)
return
var parsed = JSON.parse_string(f.get_as_text())
if typeof(parsed) != TYPE_DICTIONARY:
push_error("配置不是合法 JSON,保留上一份") # ← 关键:别把游戏搞崩
return
data = parsed
func get_num(path: String, fallback: float) -> float:
var node: Variant = data
for key in path.split("."):
if typeof(node) != TYPE_DICTIONARY or not node.has(key):
return fallback # ← 缺键就用默认值,不报错
node = node[key]
return float(node) if typeof(node) in [TYPE_FLOAT, TYPE_INT] else fallback
return fallback 很重要热重载系统最容易出的问题是:你改配置改到一半,文件是不合法的 JSON,游戏崩了。
所以两条防线:
- 解析失败 → 保留上一份配置,不要清空
- 缺少某个键 → 返回代码里的默认值,不要报错
这样你可以放心地在游戏跑着的时候乱改文件。一个会因为配置错误而崩溃的热重载系统,你用两次就不敢用了 —— 那它等于不存在。
调试面板:让调参发生在游戏里
比外部文件更进一步的做法:在游戏里直接放一个调参面板,按 F1 呼出。
这听起来很奢侈,但在 Godot 里成本很低 —— 而且它有一个外部文件比不了的优势:你在玩的同时就能调,手不用离开。
extends CanvasLayer # autoload: DebugPanel
var _target: Object = null
var _props: Array[Dictionary] = []
func watch(obj: Object, prop: String, from: float, to: float) -> void:
_props.append({ "obj": obj, "prop": prop, "from": from, "to": to })
_rebuild()
func _unhandled_input(e: InputEvent) -> void:
if e is InputEventKey and e.pressed and e.keycode == KEY_F1:
visible = not visible
func _rebuild() -> void:
for c in $Panel/VBox.get_children():
c.queue_free()
for p in _props:
var row := HBoxContainer.new()
var label := Label.new()
label.text = p.prop
label.custom_minimum_size.x = 130
var slider := HSlider.new()
slider.min_value = p.from
slider.max_value = p.to
slider.step = (p.to - p.from) / 200.0
slider.value = p.obj.get(p.prop)
slider.custom_minimum_size.x = 200
var value := Label.new()
value.text = "%.2f" % slider.value
slider.value_changed.connect(func(v):
p.obj.set(p.prop, v)
value.text = "%.2f" % v
)
row.add_child(label)
row.add_child(slider)
row.add_child(value)
$Panel/VBox.add_child(row)
# 角色的 _ready 里注册几行,就有了一整套滑块 func _ready() -> void: if OS.is_debug_build(): DebugPanel.watch(self, "jump_speed", 100.0, 600.0) DebugPanel.watch(self, "gravity", 400.0, 3000.0) DebugPanel.watch(self, "fall_mult", 1.0, 3.0) DebugPanel.watch(self, "coyote_time", 0.0, 0.3)
调参面板有个配套需求:调好之后怎么把值存回去?
加一个按钮,把所有当前值打印成可以直接粘贴的格式:
func dump() -> void: var out := "" for p in _props: out += "%s = %.3f\n" % [p.prop, p.obj.get(p.prop)] print(out) DisplayServer.clipboard_set(out) # 直接进剪贴板
没有这个按钮,你会在调了半小时之后面对一个残酷的问题:「我刚才调的那组好数值是多少来着?」
什么不该数据驱动
这一章讲了很多「抠出来」的好处,但要有个边界,否则会走向另一个极端。
- 把逻辑塞进配置。配置里出现
"condition": "hp < 30 && has_item"—— 你在用 JSON 发明一门编程语言,而它没有类型检查、没有断点、没有编辑器补全。逻辑就该写在代码里。 - 为只用一次的东西建配置系统。Boss 的特殊阶段逻辑不需要数据驱动。
- 配置层数太深。
enemies.slime.variants.elite.stats.combat.damage.base—— 没人找得到东西。
判据还是那个:你会调它超过 20 次吗?会 → 抠出来。不会 → 留在代码里,那样反而更好读。
一个更根本的边界
数据驱动的对象应该是数值和组合,不是行为。
- ✓ 「这个敌人有 20 血、移速 40、每 1.2 秒攻击一次」→ 数据
- ✓ 「这个敌人挂了 飞行 + 射击 + 护甲 三个组件」→ 数据(第 19 章的组合)
- ✗ 「这个敌人在血量低于 30% 且玩家在左边时,先后退再放技能」→ 代码
本章小结
- 迭代速度是一个设计指标,不只是工程整洁度 —— 迭代贵的时候,人会不自觉降低标准。
- 法则:任何你会调超过 20 次的东西,都值得为它做更快的调整方式。
- 三个层次:@export(十分钟,覆盖 80%)→ Resource(多套配置切换)→ 外部文件热重载。
- 被低估的一招:Godot 编辑器的「远程」标签页能改运行中场景的属性,几乎等于免费的热重载。
- Resource 化的意外收益:配置切换本身就变成了玩法(冰面、水下、变身)。
- 热重载必须有两条防线:解析失败保留旧配置、缺键返回默认值。会崩的热重载等于没有。
- 调参面板别忘了「导出当前值」按钮。
- 边界:数据驱动数值和组合,不要数据驱动行为。逻辑写在代码里。
- 卡牌:整个游戏几乎都该是数据驱动的。卡牌 = 一张表(名字、费用、效果关键词、数值)。关键设计决定是「效果关键词」的粒度 —— 关键词做得越少越通用,新卡就越是纯数据(回到第 8 章的涌现条件)。如果每张新卡都要写代码,你的关键词系统设计错了。
- 塔防:塔和敌人的数值必须是表格。而且这类游戏特别适合用电子表格当真实数据源,导出成 CSV 给游戏读 —— 因为你需要在表格里算 DPS 缺口(第 14 章)。
- 模拟经营:配方、产出率、建筑成本全是数据。这类游戏的配置量最大,值得投资一个真正的表格导入流程而不是手写 JSON。
- 叙事:对话必须外置(而且要方便翻译)。推荐用成熟格式(Ink、Yarn、或者简单的 CSV),别自己发明。这是「不要用 JSON 发明编程语言」那条的重要例外 —— 叙事流程确实需要分支逻辑,但那时你该用一个专门为此设计的语言,而不是往 JSON 里塞条件表达式。
- 平台 / 动作:手感参数是第一优先级(本章的主线)。关卡本身用 TileMap 编辑,天然就是数据驱动的。