卷 V · 造CH 19深度 19/24

游戏代码为什么不像业务代码

如果你写过后端或者 App,你会发现游戏代码有种说不出的别扭:到处是可变状态,没什么函数是纯的,一个类里塞满了看起来该拆开的东西。这不全是因为游戏程序员不讲究 —— 游戏代码有两个业务代码没有的硬约束:它每秒要跑六十次,而且世界上的一切都在同时变化。

游戏循环delta time固定步长穿墙状态机组合优于继承

第一个不同:一切都在时间里

业务代码是事件驱动的:用户点了按钮,你处理一次,然后什么都不做,等下一次。

游戏代码是循环驱动的:不管有没有事情发生,每秒都要把整个世界推进 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       # 卡太狠就丢弃,别把自己拖进死亡螺旋
◆ 固定步长解决的三件事
  1. 不会隧穿:每一步的位移都很小,不可能一步跨过一堵墙。
  2. 行为可复现:同样的输入序列永远得到同样的结果 —— 这对调试、录像回放、种子复现(第 15 章)是必需的。
  3. 数值稳定:积分误差不随帧率变化。变步长下,弹簧、摩擦这类系统会在低帧率时行为异常甚至发散。

好消息:Godot 的 _physics_process(delta) 已经是固定步长了。你不需要自己写累加器 —— 只要把所有游戏逻辑放在 _physics_process 里,而不是 _process 里。

那个 guard 变量也值得注意:如果不限制单帧内的步数,一次严重卡顿会导致要补跑上百步,而补跑本身又很慢,于是下一帧卡得更狠 —— 这叫死亡螺旋,游戏会直接卡死。

⚙ Godot:_process 和 _physics_process 怎么分
_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 冲刺,每次转移都会打在日志里。

◆ 状态机真正的价值不是「代码更整洁」

是这三件事:

  1. 非法组合变得不可能。「同时在冲刺和攻击」这种状态在结构上就不存在,你不需要用 if 去防它。
  2. 转移变得可见、可数、可断言。看那个日志 —— 你能清楚知道角色经历了什么。调试时这是巨大的优势。
  3. 加新动作是加法而不是修改。加「攀爬」只需要新增一个状态和几条转移,不用碰已有代码。

一个够用的实现

不需要什么框架,一个字典加两个函数就够了:

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,从第一天就用固定步长。