卷 IV · 数CH 14深度 14/24

数值:成长曲线与击杀时间

你在表格里调的是「攻击力从 10 涨到 200」。玩家在游戏里体验到的是「打死一个怪要几秒」。这两个数字之间隔着一整条敌人的成长曲线 —— 而很多游戏就是在这里翻车的:玩家辛辛苦苦升了二十级,攻击力翻了二十倍,然后发现打怪还是要那么久,于是他说:「这游戏升级有什么用?」

四种成长曲线击杀时间数值膨胀成长感从哪来

玩家感受不到攻击力

这是数值设计的第一条,也是最容易被忘掉的一条。

「攻击力 200」对玩家来说是一个抽象的数字。他真正能感受到的是三样东西:

  • 击杀时间(TTK, Time To Kill):打死一个敌人要几秒 / 几下
  • 生存时间(TTD, Time To Die):站着不动被打死要几秒
  • 相对变化:这次比上次快了还是慢了

所有的数值设计,最终都要翻译成这三个。下面这个 demo 把这个翻译过程画出来了 —— 左边是你调的(攻击力),右边是玩家感受的(TTK)。

重点操作:把「敌人血量同步成长」拖到 100%,然后切换不同的成长曲线。你会看到左边那条线飞上天,而右边那条几乎是平的。

◆ 这就是「升了个寂寞」的数学形式

如果敌人的血量和你的攻击力同比例增长,那么击杀时间恒定 —— 玩家在数值上升了 20 倍之后,战斗体验一模一样

这不是假设,这是大量 RPG 的真实情况。玩家的怨气来源是:他花了十小时做的事情,在体验上等于什么都没做。

更糟的是,如果敌人成长得比你快(同步 > 100%),玩家会感觉越打越吃力 —— 而他明明一直在变强。这是最伤人的一种设计。

四种成长曲线

先把常见的几种曲线摆出来,看它们各自适合什么。

曲线公式性格适合
线性 base + n × k 稳定、可预测、后期无感 短流程游戏、次要属性
等比 / 指数 base × r ⁿ 前期慢、后期爆炸 放置类、构筑类的「起飞」感
二次方 base + k × n² 加速增长但不失控 中长流程的主属性
对数 / 收敛 base + k × ln(n) 前期爽、后期趋于平缓 大多数情况下最好用
◆ 为什么对数曲线经常是最优解

因为它匹配了玩家的心理适应

  • 前期每一级都带来明显提升 → 玩家立刻感到成长,愿意继续
  • 后期提升变缓 → 但此时玩家的成长感已经转移到别的地方了(新机制、新关卡、技巧提升)

而指数曲线的问题是:前期每级提升太小(玩家感觉不到,容易早期流失),后期又大到失控(你没法设计出跟得上的敌人)。

指数增长的东西,在游戏里几乎总是麻烦。唯一的例外是那些把「数字爆炸」本身当作卖点的类型(放置游戏、割草游戏)—— 那时数字变大就是玩法。

击杀时间:那个真正该被设计的数字

与其调攻击力,不如先决定 TTK 应该是多少,再反推攻击力

这是一个思维顺序的翻转,但它会让整个数值设计变得清晰得多。

敌人类型建议 TTK为什么
杂兵(会成群出现)0.3 – 1 秒要能被快速清掉,否则清怪变成劳动
精英3 – 8 秒长到需要用技巧,短到不会烦
Boss60 – 180 秒要能容纳「几个阶段」的结构
玩家的 TTD≥ 3 次受击一击必杀会让人不敢尝试(除非那就是你的设计)
◆ 一个关于杂兵的硬规则

杂兵的 TTK 如果超过 2 秒,你的游戏会变得非常累。

算笔账:一关有 40 个杂兵,每个 3 秒 = 2 分钟纯粹的「敲血条」时间。而这段时间里玩家没有做任何有意思的决策 —— 他只是在等血条走完。

这是「清怪很烦」这个评价的数学来源。修法不是减少敌人数量(那样关卡会变空),而是降低单个杂兵的血量,让清怪变成一个流畅的过程而不是一系列小战斗。

《DOOM 永恒》《吸血鬼幸存者》都把杂兵 TTK 压得极低,所以尽管敌人成百上千,玩起来是爽而不是累。

反推的例子

假设你决定:杂兵 TTK = 0.6 秒,玩家攻击间隔 0.3 秒(也就是两下打死)。

已知:TTK = 0.6 s,攻击间隔 = 0.3 s  →  需要 2 次命中
决定:玩家 1 级攻击力 = 10
推出:1 级杂兵血量 = 20

到 10 级时,我希望玩家能「一下打死」杂兵(成长感!)
决定:10 级攻击力 = 24(对数曲线)
推出:10 级杂兵血量 ≤ 24  →  就设 22

结果:杂兵血量从 20 涨到 22(几乎不涨)
     玩家攻击力从 10 涨到 24
     TTK 从 2 下变成 1 下  ← 这就是玩家能感受到的成长

注意最后那个变化:从 2 下变成 1 下。这是一个离散的、明确的、玩家一定会注意到的变化 —— 比「攻击力 +140%」这个抽象数字有效一百倍。

◆ 成长感来自「跨过阈值」,不是来自数字变大

这是本章最重要的一句话。

玩家感受到成长的时刻,是那些离散的、可命名的时刻:

  • 从「两下打死」变成「一下打死」
  • 从「必须躲这个攻击」变成「可以硬吃一下」
  • 从「这个坡跳不上去」变成「跳得上去了」
  • 从「这个精英打不过」变成「打得过了」

所以数值设计的真正工作,是安排这些阈值什么时候被跨过 —— 而不是让曲线好看。

推论:一个好的成长曲线,应该在几个特定的等级上「刚好跨过」某个阈值。而不是每级都平滑地涨一点点(那样玩家一次阈值都跨不过,全程无感)。

数值膨胀:为什么你的数字最后会变成一串零

长期项目的经典病。第一章的怪 20 血,最后一章的怪 2000000 血。

它是怎么发生的:

设计师想让第 2 章比第 1 章「更强」  → 数值 ×2
第 3 章要比第 2 章更强              → 再 ×2
……
第 10 章                            → 512 倍
玩家看到的伤害数字:       10 → 5120 → 后面全是零

问题不只是不好看:

  • 玩家失去了数字的直觉。「12500 伤害」和「13800 伤害」哪个好?大脑要算,而算不动就干脆不看了。
  • 早期内容彻底作废。第 1 章的装备在第 3 章就是垃圾,你精心设计的东西只被用了半小时。
  • 浮点精度和整数溢出(真的会遇到)。
◆ 三条对策
  1. 用乘法修饰,不用加法堆叠。与其「+50 攻击力」,不如「+15% 攻击力」。前者会让数值线性膨胀,后者天然收敛。
  2. 设一个数值上限,然后横向扩展。攻击力封顶在 100,之后的成长改成「新能力」「新组合」「新效果」而不是更大的数。这是《杀戮尖塔》的做法 —— 它的伤害数字从头到尾都是两位数。
  3. 如果一定要膨胀,就换单位。1000 伤害 → 显示成「1.0k」。这是放置类游戏的标准做法。

对个人项目,第 2 条最好:数值封顶,成长靠机制。因为它同时解决了数值膨胀和「后期内容做不完」两个问题。

怎么调:从哪里开始

面对一堆需要定的数字,有个实用的顺序:

  1. 先定「一个基准单位」。比如:玩家 1 级攻击力 = 10。所有其他数值都相对它来定。
  2. 再定几个关键的 TTK。杂兵几下死、精英几秒死、Boss 几分钟。
  3. 反推血量。而不是先拍血量再调攻击。
  4. 然后定阈值时刻。「我希望玩家在第 3 关能一下打死杂兵」—— 这决定了成长曲线的形状。
  5. 最后才填表格。
✕ 不要一开始就做表格

新手常见流程:打开表格软件,建 20 行「等级」,然后开始填攻击力、血量、经验值……

这个流程的问题是你在填数字,而不是在做设计决定。填完之后你有一张漂亮的表,但你不知道玩起来是什么感觉。

正确的流程是先回答那几个体验问题(TTK 多少?什么时候跨过阈值?),表格只是把答案写下来的地方。

⚙ Godot 落地:数值放在 Resource 里,不要写死在脚本里

这是第 20 章「数据驱动」的前哨。数值一定会改很多次,把它们做成可以在编辑器里直接编辑的资源:

class_name EnemyStats
extends Resource

@export var display_name: String = "史莱姆"
@export var max_hp: int = 20
@export var damage: int = 5
@export var move_speed: float = 40.0
@export_range(0.0, 5.0) var attack_interval: float = 1.2

# 把「设计意图」也写进来,未来的你会感谢自己
@export_group("设计意图")
@export var intended_ttk: float = 0.6      # 期望的击杀时间
@export var tier: String = "杂兵"
# 敌人脚本只引用资源,不关心具体数字
extends CharacterBody2D

@export var stats: EnemyStats

func _ready() -> void:
	hp = stats.max_hp

好处:改数值不用碰代码、能建多个变体(普通史莱姆 / 精英史莱姆共用同一个脚本)、而且 intended_ttk 那一栏让你能写一个自动校验脚本

@tool
extends EditorScript

func _run() -> void:
	var player_dps := 10.0 / 0.3          # 攻击力 / 攻击间隔
	for path in _all_enemy_stats():
		var s: EnemyStats = load(path)
		var actual := s.max_hp / player_dps
		if absf(actual - s.intended_ttk) > s.intended_ttk * 0.3:
			push_warning("%s:期望 TTK %.1fs,实际 %.1fs —— 血量该调了"
				% [s.display_name, s.intended_ttk, actual])

让工具替你盯住设计意图和实际数值的偏离。手工核对二十个敌人的数值,你一定会漏。

本章小结

  • 玩家感受不到攻击力,他感受的是 TTK、TTD、相对变化
  • 敌人血量和玩家攻击力同步增长 = 击杀时间恒定 = 升了个寂寞
  • 四种曲线里,对数 / 收敛通常最好用;指数增长在游戏里几乎总是麻烦。
  • 先定 TTK,再反推血量,不要反过来。杂兵 TTK 超过 2 秒,游戏会变得很累。
  • 成长感来自跨过阈值(两下变一下),不是来自数字变大。数值设计的真正工作是安排阈值时刻。
  • 治数值膨胀:乘法修饰、数值封顶 + 横向扩展、换单位。个人项目首选封顶。
  • 把数值做成 Resource,并把设计意图一起写进去,让工具自动校验偏离。
⇄ 换个类型
  • 卡牌 / 肉鸽:TTK 的等价物是「几回合打完一场战斗」(通常 3–8 回合最舒服)。这类游戏是「数值封顶 + 横向扩展」的典范:伤害永远两位数,成长全靠组合如果你的卡牌游戏后期开始出现四位数伤害,多半是设计出了问题。
  • 塔防:核心数值是「DPS 缺口」 —— 玩家的总输出 vs 一波敌人的总血量。设计一关就是设计这条缺口曲线:什么时候刚好够、什么时候差一点(逼玩家升级)。先画缺口曲线,再定塔的数值。
  • 模拟经营:核心数值是「产出 / 消耗比」回本周期。这类游戏的膨胀问题最严重(复利),第 17 章会专门讲。
  • 平台 / 动作:数值最少的类型,但也有 —— 跳跃高度和格子尺寸的关系是这里最重要的数值。建议:先定格子(16px),再定跳跃高度为格子的整数倍多一点(比如 2.3 格),这样关卡设计时心里有数。本书那台引擎跳 37 像素 = 2.3 格,就是这么定的。
  • 叙事解谜:几乎没有数值,但有一个隐藏的:「玩家卡住的容忍时长」(通常 5–10 分钟)。超过就要给提示。这是这类游戏唯一需要认真调的「数值」。