10. 测试与调试
2026/4/14大约 6 分钟
10. 测试与调试
游戏开发中,bug 是不可避免的。好的测试和调试习惯能让你更快找到问题、更有信心发布游戏。
调试基础
打印日志
最简单的调试方式就是打印信息。
C#
public override void _Ready()
{
GD.Print("场景加载完成");
GD.PrintErr("这是一条错误信息"); // 红色显示
GD.PushWarning("这是一条警告"); // 黄色显示
}
// 打印变量
public override void _Process(double delta)
{
GD.Print($"玩家位置: {Position}, 速度: {Velocity}");
}GDScript
func _ready():
print("场景加载完成")
printerr("这是一条错误信息") # 红色显示
push_warning("这是一条警告") # 黄色显示
# 打印变量
func _process(delta):
print("玩家位置: ", position, " 速度: ", velocity)断点调试
Godot 内置了调试器,可以在代码中设置断点:
- 在脚本编辑器中点击行号左侧,出现红点即为断点
- 运行游戏,程序执行到断点时会暂停
- 在底部调试面板查看变量值、调用栈
C#
public override void _Process(double delta)
{
// 条件断点:只在特定条件下触发
if (Health <= 0)
{
// 在这里设置断点,检查死亡时的状态
GD.Print($"玩家死亡,位置: {Position}");
}
}GDScript
func _process(delta):
# 条件断点:只在特定条件下触发
if health <= 0:
# 在这里设置断点,检查死亡时的状态
print("玩家死亡,位置: ", position)
breakpoint # GDScript 专用,强制触发断点性能分析
使用 Profiler
Godot 内置性能分析器,帮你找到性能瓶颈:
- 运行游戏
- 打开 调试 → 调试器 → 性能分析器
- 点击"开始"录制
- 操作游戏几秒后点击"停止"
- 查看各函数的耗时
重点关注:
_process和_physics_process的耗时- 渲染相关函数(Draw Calls 数量)
- 物理计算耗时
监控关键指标
C#
public override void _Process(double delta)
{
// 在屏幕上显示性能信息(开发阶段用)
var fps = Engine.GetFramesPerSecond();
var drawCalls = RenderingServer.GetRenderingInfo(
RenderingServer.RenderingInfo.TotalDrawCallsInFrame
);
// 显示到 Label 节点
if (_debugLabel != null)
{
_debugLabel.Text = $"FPS: {fps}\nDraw Calls: {drawCalls}";
}
}GDScript
func _process(delta):
# 在屏幕上显示性能信息(开发阶段用)
var fps = Engine.get_frames_per_second()
var draw_calls = RenderingServer.get_rendering_info(
RenderingServer.RENDERING_INFO_TOTAL_DRAW_CALLS_IN_FRAME
)
# 显示到 Label 节点
if debug_label:
debug_label.text = "FPS: %d\nDraw Calls: %d" % [fps, draw_calls]自定义性能监控
C#
using Godot;
public partial class PerformanceMonitor : Node
{
private float _timer = 0f;
private int _frameCount = 0;
public override void _Process(double delta)
{
_timer += (float)delta;
_frameCount++;
// 每秒输出一次统计
if (_timer >= 1.0f)
{
GD.Print($"平均FPS: {_frameCount}");
GD.Print($"内存使用: {OS.GetStaticMemoryUsage() / 1024 / 1024} MB");
_timer = 0f;
_frameCount = 0;
}
}
}GDScript
extends Node
var timer: float = 0.0
var frame_count: int = 0
func _process(delta):
timer += delta
frame_count += 1
# 每秒输出一次统计
if timer >= 1.0:
print("平均FPS: ", frame_count)
print("内存使用: ", OS.get_static_memory_usage() / 1024 / 1024, " MB")
timer = 0.0
frame_count = 0单元测试(GUT 框架)
GUT(Godot Unit Test) 是 Godot 最流行的单元测试框架。
安装 GUT
- 在 Godot 资产库中搜索 "GUT"
- 下载并安装到项目
- 或从 GitHub 下载放入
addons/gut/目录
编写测试
C#
// 测试文件放在 tests/ 目录下
// 文件名格式:test_xxx.cs
using Godot;
using GdUnit4; // 使用 GdUnit4(C# 推荐)
[TestSuite]
public class TestPlayerHealth
{
private Player _player;
[Before]
public void Setup()
{
_player = new Player();
_player.MaxHealth = 100;
_player.Health = 100;
}
[TestCase]
public void TestTakeDamage()
{
_player.TakeDamage(30);
AssertThat(_player.Health).IsEqual(70);
}
[TestCase]
public void TestDeath()
{
_player.TakeDamage(100);
AssertThat(_player.IsDead).IsTrue();
}
[TestCase]
public void TestHealOverMax()
{
_player.Heal(50);
// 治疗不能超过最大血量
AssertThat(_player.Health).IsEqual(100);
}
}GDScript
# 测试文件放在 tests/ 目录下
# 文件名格式:test_xxx.gd
extends GutTest
var player
func before_each():
player = preload("res://scenes/player/Player.tscn").instantiate()
player.max_health = 100
player.health = 100
func after_each():
player.queue_free()
func test_take_damage():
player.take_damage(30)
assert_eq(player.health, 70, "受到30点伤害后血量应为70")
func test_death():
player.take_damage(100)
assert_true(player.is_dead, "血量归零后应该死亡")
func test_heal_over_max():
player.heal(50)
# 治疗不能超过最大血量
assert_eq(player.health, 100, "治疗不能超过最大血量")测试游戏逻辑
GDScript
# 测试棋牌游戏规则
extends GutTest
var chess_rules
func before_each():
chess_rules = preload("res://scripts/chess/ChessRules.gd").new()
func test_pawn_valid_move():
# 测试兵的合法移动
var from = Vector2i(4, 6)
var to = Vector2i(4, 5)
assert_true(
chess_rules.is_valid_move("pawn", from, to, "white"),
"兵向前一步应该合法"
)
func test_pawn_invalid_move():
# 测试兵的非法移动
var from = Vector2i(4, 6)
var to = Vector2i(4, 3) # 向前3步
assert_false(
chess_rules.is_valid_move("pawn", from, to, "white"),
"兵向前3步应该非法"
)日志系统
项目大了之后,简单的 print 不够用,需要一个分级日志系统。
C#
using Godot;
public static class Logger
{
public enum Level { Debug, Info, Warning, Error }
// 当前日志级别(发布时设为 Warning 或 Error)
public static Level CurrentLevel = Level.Debug;
public static void Debug(string message, string tag = "")
{
if (CurrentLevel <= Level.Debug)
GD.Print($"[DEBUG]{(tag != "" ? $"[{tag}]" : "")} {message}");
}
public static void Info(string message, string tag = "")
{
if (CurrentLevel <= Level.Info)
GD.Print($"[INFO]{(tag != "" ? $"[{tag}]" : "")} {message}");
}
public static void Warning(string message, string tag = "")
{
if (CurrentLevel <= Level.Warning)
GD.PushWarning($"[WARN]{(tag != "" ? $"[{tag}]" : "")} {message}");
}
public static void Error(string message, string tag = "")
{
GD.PrintErr($"[ERROR]{(tag != "" ? $"[{tag}]" : "")} {message}");
}
}
// 使用示例
public partial class Player : CharacterBody3D
{
public override void _Ready()
{
Logger.Info("玩家初始化完成", "Player");
}
public void TakeDamage(int damage)
{
Logger.Debug($"受到伤害: {damage}", "Player");
Health -= damage;
if (Health <= 0)
Logger.Warning("玩家死亡", "Player");
}
}GDScript
# autoload/Logger.gd(设为自动加载)
extends Node
enum Level { DEBUG, INFO, WARNING, ERROR }
# 当前日志级别(发布时设为 WARNING 或 ERROR)
var current_level: Level = Level.DEBUG
func debug(message: String, tag: String = "") -> void:
if current_level <= Level.DEBUG:
var prefix = "[DEBUG]" + ("[%s]" % tag if tag else "")
print(prefix + " " + message)
func info(message: String, tag: String = "") -> void:
if current_level <= Level.INFO:
var prefix = "[INFO]" + ("[%s]" % tag if tag else "")
print(prefix + " " + message)
func warning(message: String, tag: String = "") -> void:
if current_level <= Level.WARNING:
var prefix = "[WARN]" + ("[%s]" % tag if tag else "")
push_warning(prefix + " " + message)
func error(message: String, tag: String = "") -> void:
var prefix = "[ERROR]" + ("[%s]" % tag if tag else "")
printerr(prefix + " " + message)
# 使用示例(在其他脚本中)
# Logger.info("玩家初始化完成", "Player")
# Logger.debug("受到伤害: " + str(damage), "Player")常见 Bug 排查
物理穿透问题
症状:角色穿过墙壁或地板
排查步骤:
- 检查碰撞形状是否正确设置
- 检查碰撞层(Layer)和碰撞掩码(Mask)是否匹配
- 高速移动时启用
continuous_cd(连续碰撞检测)
C#
public override void _Ready()
{
// 启用连续碰撞检测,防止高速穿透
// RigidBody3D 专用
var body = GetNode<RigidBody3D>("Body");
body.ContinuousCd = true;
}GDScript
func _ready():
# 启用连续碰撞检测,防止高速穿透
# RigidBody3D 专用
$Body.continuous_cd = true内存泄漏
症状:游戏运行时间越长越卡
排查步骤:
- 检查动态创建的节点是否调用了
queue_free() - 检查信号连接是否在节点销毁时断开
- 使用 Godot 调试器的"内存"面板监控
C#
// 错误:创建了节点但没有释放
public void SpawnBullet()
{
var bullet = BulletScene.Instantiate<Bullet>();
AddChild(bullet);
// 忘记在子弹消失时调用 bullet.QueueFree()
}
// 正确:子弹脚本中自动释放
public partial class Bullet : Area3D
{
public override void _Ready()
{
// 2秒后自动销毁
var timer = GetTree().CreateTimer(2.0);
timer.Timeout += QueueFree;
}
private void OnBodyEntered(Node3D body)
{
// 碰到物体后销毁
QueueFree();
}
}GDScript
# 正确:子弹脚本中自动释放
extends Area3D
func _ready():
# 2秒后自动销毁
await get_tree().create_timer(2.0).timeout
queue_free()
func _on_body_entered(body):
# 碰到物体后销毁
queue_free()信号连接问题
症状:事件触发了但没有响应
排查步骤:
- 确认信号已连接(调试器 → 信号面板)
- 确认接收方法名称拼写正确
- 确认节点没有被提前销毁
C#
public override void _Ready()
{
// 检查信号是否已连接
var button = GetNode<Button>("UI/Button");
// 避免重复连接
if (!button.IsConnected(Button.SignalName.Pressed, Callable.From(OnButtonPressed)))
{
button.Pressed += OnButtonPressed;
}
}GDScript
func _ready():
var button = $UI/Button
# 避免重复连接
if not button.pressed.is_connected(_on_button_pressed):
button.pressed.connect(_on_button_pressed)调试工具推荐
| 工具 | 用途 |
|---|---|
| Godot 内置调试器 | 断点、变量监视、调用栈 |
| Godot Profiler | 性能分析、帧耗时 |
| GUT | 单元测试框架 |
| GdUnit4 | C# 单元测试框架 |
| Remote Scene Tree | 运行时查看场景树 |
发布前检查清单
