卷 V · 造CH 20深度 20/24

让游戏可调:把数值从代码里抠出来

这本书从第一章开始就在说同一件事:好玩是调出来的,不是设计出来的。那么真正决定你游戏质量上限的,其实是一个很朴素的工程指标 —— 从「改一个数字」到「感受到结果」,要花多少秒?如果是两分钟,你一天调五十次;如果是两秒,你一天调三千次。这个差距,就是最终手感的差距。

数据驱动迭代速度热重载调试面板Resource

先感受一下

下面这个 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 好用十倍的细节
  • @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.tresunderwater.tresice.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,游戏崩了。

所以两条防线:

  1. 解析失败 → 保留上一份配置,不要清空
  2. 缺少某个键 → 返回代码里的默认值,不要报错

这样你可以放心地在游戏跑着的时候乱改文件。一个会因为配置错误而崩溃的热重载系统,你用两次就不敢用了 —— 那它等于不存在。

调试面板:让调参发生在游戏里

比外部文件更进一步的做法:在游戏里直接放一个调参面板,按 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 编辑,天然就是数据驱动的。