游戏代码为什么不像业务代码
如果你写过后端或者 App,你会发现游戏代码有种说不出的别扭:到处是可变状态,没什么函数是纯的,一个类里塞满了看起来该拆开的东西。这不全是因为游戏程序员不讲究 —— 游戏代码有两个业务代码没有的硬约束:它每秒要跑六十次,而且世界上的一切都在同时变化。
第一个不同:一切都在时间里
业务代码是事件驱动的:用户点了按钮,你处理一次,然后什么都不做,等下一次。
游戏代码是循环驱动的:不管有没有事情发生,每秒都要把整个世界推进 60 次。
while 游戏还在跑:
读输入
更新所有东西 ← 每个敌人、每颗子弹、每个粒子、每个计时器
检测碰撞
渲染
等到下一帧
这个结构带来一个直接后果:所有的「多久」都必须显式地写出来。
业务代码里你会写 sleep(1000) 或者 await delay(1s)。游戏代码里不能这么干 —— 你一 sleep,整个世界就冻住了。你必须写成:
var cooldown: float = 0.0 func _physics_process(delta: float) -> void: if cooldown > 0.0: cooldown -= delta # 手动推进时间 if can_shoot and cooldown <= 0.0: shoot() cooldown = 0.35
这就是为什么游戏代码里到处都是这种手动递减的计时器变量。它不是坏品味,它是这个执行模型的必然结果。
delta time:那个会让子弹穿墙的坑
既然每帧要推进世界,那推进多少?答案是 delta —— 距离上一帧过了多久。
这里有三种写法,看起来只差一点,后果差很远。下面这个 demo 里三种都在跑同一颗子弹。把「模拟卡顿」拖到 200ms 以上,选第 ② 种,盯着那条粗线。
写法 ①:不乘 delta
position.x += 7 # 每帧走 7 像素
在 60 帧下,这是每秒 420 像素。在 144 帧的显示器上,它变成每秒 1008 像素 —— 同一个游戏在不同机器上速度完全不同。
这是最经典的新手 bug,而且它在你自己的机器上永远测不出来。
写法 ②:乘 delta
position.x += 420 * delta # 每秒 420 像素,帧率无关
速度对了。但看 demo:卡顿时那条粗线会一下子跨到墙的另一边。
原因:如果这一帧卡了 250 毫秒,那么 420 × 0.25 = 105 像素 —— 子弹一步迈过了整堵墙。而碰撞检测只检查当前位置,它没在墙里,所以判定不到。
这就是隧穿(tunneling)。它的症状是间歇性的、不可复现的「子弹偶尔打不中」「角色偶尔掉出地图」—— 最难查的那类 bug。
写法 ③:固定步长
把大步拆成很多小步:
const STEP := 1.0 / 120.0 var _accumulator: float = 0.0 func _process(delta: float) -> void: _accumulator += delta var guard := 0 while _accumulator >= STEP and guard < 40: _step(STEP) # 物理永远走 1/120 秒 _accumulator -= STEP guard += 1 if guard >= 40: _accumulator = 0.0 # 卡太狠就丢弃,别把自己拖进死亡螺旋
- 不会隧穿:每一步的位移都很小,不可能一步跨过一堵墙。
- 行为可复现:同样的输入序列永远得到同样的结果 —— 这对调试、录像回放、种子复现(第 15 章)是必需的。
- 数值稳定:积分误差不随帧率变化。变步长下,弹簧、摩擦这类系统会在低帧率时行为异常甚至发散。
好消息:Godot 的 _physics_process(delta) 已经是固定步长了。你不需要自己写累加器 —— 只要把所有游戏逻辑放在 _physics_process 里,而不是 _process 里。
那个 guard 变量也值得注意:如果不限制单帧内的步数,一次严重卡顿会导致要补跑上百步,而补跑本身又很慢,于是下一帧卡得更狠 —— 这叫死亡螺旋,游戏会直接卡死。
_physics_process | _process | |
|---|---|---|
| 调用频率 | 固定(默认 60/s) | 每渲染帧,会抖 |
| 放什么 | 移动、碰撞、游戏逻辑、状态机、计时器 | 纯表现:插值、UI 更新、粒子、摄像机平滑 |
| 判据 | 「会影响游戏结果吗」→ 是 | 「只影响画面吗」→ 是 |
动作游戏可以把物理帧率提到 120:Engine.physics_ticks_per_second = 120。手感会明显更跟手(第 10 章),代价是 CPU 翻倍 —— 对 2D 游戏通常无所谓。
第二个不同:状态爆炸
业务代码里一个对象通常有少数几个状态。游戏里的一个角色,可能同时是:在地上 / 在空中 / 在冲刺 / 在受伤 / 在攻击 / 无敌中 / 被击退中……而且这些状态互相影响。
新手的写法必然是这样开始的:
func _physics_process(delta: float) -> void:
if not is_hurt:
if Input.is_action_pressed("attack") and not is_attacking and not is_dashing:
is_attacking = true
# ...
if Input.is_action_just_pressed("dash") and can_dash and not is_attacking:
is_dashing = true
# ...
if is_dashing:
# ...
if dash_time <= 0:
is_dashing = false
# …… 再来二十个 if
三个月后这个函数会有四百行,而且每加一个新动作,你都要回来修改所有已有的 if 条件。更糟的是会出现「攻击的同时冲刺的同时受伤」这种你没考虑过的组合,然后角色卡在某个状态里出不来。
解法:状态机
核心思想很简单:任何时刻只处于一个状态,转移规则集中在一个地方。
下面这个 demo 左边是真的在跑的角色,右边是它此刻所处的状态。方向键移动、空格跳、X 冲刺,每次转移都会打在日志里。
是这三件事:
- 非法组合变得不可能。「同时在冲刺和攻击」这种状态在结构上就不存在,你不需要用 if 去防它。
- 转移变得可见、可数、可断言。看那个日志 —— 你能清楚知道角色经历了什么。调试时这是巨大的优势。
- 加新动作是加法而不是修改。加「攀爬」只需要新增一个状态和几条转移,不用碰已有代码。
一个够用的实现
不需要什么框架,一个字典加两个函数就够了:
extends CharacterBody2D
enum State { IDLE, RUN, JUMP, FALL, DASH, LAND }
var state: State = State.IDLE
var state_time: float = 0.0
func _physics_process(delta: float) -> void:
state_time += delta
_transition() # 先决定这一帧该处于什么状态
_update_state(delta) # 再执行那个状态的逻辑
move_and_slide()
func _set_state(next: State) -> void:
if next == state:
return
_exit_state(state)
state = next
state_time = 0.0
_enter_state(next)
# 所有转移规则集中在这一个函数里 —— 这就是全部的价值
func _transition() -> void:
match state:
State.DASH:
if state_time >= DASH_TIME:
_set_state(State.FALL if not is_on_floor() else State.IDLE)
State.LAND:
if state_time >= LAND_TIME:
_set_state(State.RUN if absf(velocity.x) > 6.0 else State.IDLE)
_:
if Input.is_action_just_pressed("dash") and can_dash:
_set_state(State.DASH)
elif not is_on_floor():
_set_state(State.JUMP if velocity.y < 0.0 else State.FALL)
else:
_set_state(State.RUN if absf(velocity.x) > 6.0 else State.IDLE)
func _enter_state(s: State) -> void:
match s:
State.DASH:
velocity = Vector2(facing * DASH_SPEED, 0.0)
can_dash = false
$Sprite.play("dash")
State.JUMP:
velocity.y = -JUMP_SPEED
$Sprite.play("jump")
State.LAND:
_spawn_dust() # 第 11 章的落地反馈,就挂在这里
_enter_state 是个宝库注意 _enter_state 里那些调用:播动画、生成烟尘、清空速度。
「进入状态时做一次」这个语义,正好对应了卷 III 里几乎所有的反馈需求。起跳的挤压、落地的震屏、受伤的闪白 —— 全都是「进入某个状态的那一帧做一次」。
有了状态机,这些东西有了一个天然的、唯一的归宿。而在那堆 if 里,它们只能靠 if not was_on_floor and is_on_floor 这种脆弱的边沿检测来触发。
一、状态太细。把「跑」拆成「起步 / 匀速 / 减速」三个状态,通常是过度设计。状态应该对应玩家能感知到的不同行为模式,而不是内部的每一个阶段。
二、太早引入。如果你的角色只有「站」和「跳」,那两个 if 就够了,硬套状态机只是增加噪音。等到你有 4 个以上状态、或者已经出现「状态卡住」的 bug 时再重构 —— 那时它的价值是显而易见的。
第三个不同:组合优于继承
业务代码里继承用得挺顺。游戏里它会很快崩溃,原因是游戏对象的特性是正交的。
假设你有敌人:会飞的、会射击的、有护甲的、会爆炸的。用继承:
Enemy
├─ FlyingEnemy
│ ├─ FlyingShootingEnemy
│ │ └─ FlyingShootingArmoredEnemy ← 已经开始失控
│ └─ FlyingExplodingEnemy
└─ GroundEnemy
├─ GroundShootingEnemy
└─ ……
4 个特性 = 16 种组合。继承树处理不了这个,你会开始复制粘贴代码。
组合的做法是把每个特性做成一个独立的节点,挂上去:
Enemy (CharacterBody2D) ├─ HealthComponent 血量、受伤、死亡信号 ├─ FlyMovement 或者换成 WalkMovement ├─ ShooterComponent 定期朝玩家射击 ├─ ArmorComponent 减伤 └─ ExplodeOnDeath 死亡时爆炸
要一个新敌人?挂不同的组件组合,不写一行新代码。这正好呼应第 8 章那条「用属性和 group,不用类型判断」的纪律 —— 都是在避免特判。
class_name HealthComponent extends Node signal damaged(amount: int) signal died @export var max_hp: int = 20 var hp: int func _ready() -> void: hp = max_hp func take_damage(amount: int) -> void: if hp <= 0: return hp -= amount damaged.emit(amount) if hp <= 0: died.emit()
其他组件监听信号,而不是互相直接调用:
class_name ExplodeOnDeath extends Node @export var health: HealthComponent @export var radius: float = 48.0 func _ready() -> void: health.died.connect(_on_died) func _on_died() -> void: # HealthComponent 完全不知道 ExplodeOnDeath 的存在 _spawn_explosion(radius)
不是所有东西都该组件化 —— 过度组件化会让代码变成一堆找不到入口的碎片。
判据:这个特性会不会出现在多个不相关的对象上?
- 「血量」会出现在敌人、玩家、可破坏的箱子上 → 组件
- 「Boss 第二阶段的特殊逻辑」只有这一个 Boss 有 → 就写在 Boss 脚本里
本章小结
- 游戏代码是循环驱动而非事件驱动,所以「多久」必须手动写成递减的计时器。
- 三种循环写法:不乘 delta(帧率相关)、乘 delta(会隧穿)、固定步长(正确)。
- Godot 的
_physics_process已经是固定步长 —— 游戏逻辑放它里面,_process只放表现。 - 累加器循环要有 guard 上限,否则严重卡顿会引发死亡螺旋。
- 状态机的价值不是整洁,是非法组合不可能、转移可见、加动作是加法。
_enter_state是卷 III 所有「进入时做一次」反馈的天然归宿。- 状态机别太细、别太早 —— 4 个以上状态再上。
- 游戏对象的特性是正交的,所以组合优于继承;判据是「会不会出现在多个不相关的对象上」。
- 卡牌 / 回合制:不需要固定步长物理,但非常需要状态机 —— 回合流程(抽牌 → 出牌 → 敌人行动 → 结算)就是一个状态机。而且这类游戏还要处理动画队列:逻辑瞬间算完,但表现要排队播放。把「逻辑状态」和「表现状态」分开,是这类游戏最重要的架构决定。
- 塔防:性能是主要约束(几百个单位)。这里组合优于继承的价值最大,但要小心:几百个节点各自
_process会很慢。做法是用一个管理器统一遍历数组,而不是每个单位自己一个节点。 - 模拟经营:核心是时间尺度的分离 —— 渲染 60 帧,但模拟可能一秒只跑一次(甚至可变速)。绝不要把模拟绑在渲染帧上,否则加速播放时行为会变。
- 叙事解谜:状态机用来管剧情进度和对话流程。这类游戏的经典 bug 是「玩家用非预期的顺序触发了事件」—— 状态机 + 明确的前置条件是唯一的解法。
- 平台 / 动作:本章全部适用。额外一条:固定步长对录像回放和 speedrun 社区是刚需。如果你希望玩家能分享录像或者做 TAS,从第一天就用固定步长。