系列定位:Unity 老版本兼容(.NET 3.5 / C# 4)+ 项目驱动。
本章目标:给前 14 章的基础设施补齐自动化验证体系,让每次改动都有“可量化正确性保障”。
学习目标
完成本章后,你应该能做到:
- 为核心模块建立统一测试入口(单测 + 回放对比 + 性能烟测)。
- 把“业务规则”转成可执行断言,而不是靠人工观察。
- 建立稳定的回归样本集(固定 seed + 固定输入 + 固定预期)。
- 让 CI 在数分钟内判定本次改动是否破坏核心战斗行为。
为什么这一章是 C# 路线收官关键
前面章节已经有:
- 命令系统
- 状态机
- 技能系统
- Buff 系统
- 伤害管线
- 仇恨系统
- 回放落盘
- 生命周期治理
如果没有自动化回归,这些模块越多,回归成本越高,迭代速度会持续下降。
工程化的转折点不是“再加功能”,而是“把已有功能变成可验证资产”。
测试体系分层
建议分三层:
- L1 单元测试:纯逻辑,无引擎依赖,毫秒级。
- L2 场景回放测试:加载回放数据,验证关键指标一致。
- L3 性能烟测:固定时长跑战斗循环,检测 GC/对象数异常。
测试目录建议
tests/
Unit/
CommandBusTests.cs
StateMachineTests.cs
SkillValidatorTests.cs
BuffStackTests.cs
DamagePipelineTests.cs
ThreatSelectorTests.cs
Replay/
ReplayDeterminismTests.cs
ReplayCompatibilityTests.cs
Data/
baseline_2v2_001.rpl
baseline_boss_001.rpl
Perf/
BattleLoopSmokeTests.cs
项目驱动:先落地最关键的 6 组单测
1. 命令系统:顺序与回收
[Test]
public void CommandBus_Should_ExecuteInSequence_And_Recycle()
{
var bus = new CommandBus("test", 8, 16);
var trace = new List<int>();
bus.Enqueue(new TestCommand(1, trace), 0f);
bus.Enqueue(new TestCommand(2, trace), 0.01f);
bus.Enqueue(new TestCommand(3, trace), 0.02f);
bus.Tick(new CommandContext(null, null), null);
Assert.AreEqual(3, trace.Count);
Assert.AreEqual(1, trace[0]);
Assert.AreEqual(2, trace[1]);
Assert.AreEqual(3, trace[2]);
}
2. 状态机:优先级切换
[Test]
public void GuardBrain_Should_Prioritize_Dead_Over_Hit()
{
var actor = new Actor();
actor.Hp = 0f;
actor.IsHit = true;
var fixture = GuardBrainFixture.Build(actor);
fixture.Brain.Tick(0.016f);
Assert.AreEqual("Dead", fixture.Fsm.CurrentName);
}
3. 技能系统:冷却与蓝耗校验
[Test]
public void SkillComponent_Should_Reject_When_Cooldown_Or_Mana_NotEnough()
{
var setup = SkillFixture.Create();
string reason;
var ok1 = setup.PlayerSkills.TryCast(setup.Player, setup.Target, 1001, 1.0f, out reason);
Assert.IsTrue(ok1);
var ok2 = setup.PlayerSkills.TryCast(setup.Player, setup.Target, 1001, 1.1f, out reason);
Assert.IsFalse(ok2);
Assert.AreEqual("skill_cooldown", reason);
}
4. Buff 系统:叠层上限
[Test]
public void EffectContainer_Should_Clamp_Stack_To_Max()
{
var actor = new Actor();
var bus = new EventBus();
var effects = new EffectContainer(actor, bus);
var cfg = new EffectConfig
{
Id = 3001,
Name = "Poison",
Type = EffectType.Dot,
DurationSeconds = 6f,
TickIntervalSeconds = 1f,
MaxStacks = 3,
StackPolicy = StackPolicy.StackCount,
Value = 5f
};
effects.Apply(cfg, 1);
effects.Apply(cfg, 1);
effects.Apply(cfg, 1);
effects.Apply(cfg, 1);
Assert.AreEqual(3, effects.GetStack(3001));
}
5. 伤害管线:固定 seed 一致性
[Test]
public void DamagePipeline_Should_Be_Deterministic_With_Same_Seed()
{
var fixture = DamageFixture.Create();
var req = fixture.CreateRequest(seed: 123456);
var r1 = fixture.Pipeline.Execute(req, fixture.Context);
var r2 = fixture.Pipeline.Execute(req, fixture.ContextClone());
Assert.AreEqual(r1.IsHit, r2.IsHit);
Assert.AreEqual(r1.IsCritical, r2.IsCritical);
Assert.AreEqual(r1.FinalDamage, r2.FinalDamage);
}
6. 仇恨系统:切换防抖
[Test]
public void ThreatSelector_Should_Not_Switch_When_Delta_Below_Threshold()
{
var fixture = ThreatFixture.Create(threshold: 25f);
fixture.Table.AddThreat(1001, 200f, 1f);
fixture.Table.AddThreat(1002, 210f, 1f);
fixture.Component.Tick(1f, 0.016f);
var first = fixture.Component.CurrentTarget.Id;
fixture.Table.AddThreat(1002, 5f, 1.2f); // 仅微小领先
fixture.Component.Tick(1.2f, 0.016f);
Assert.AreEqual(first, fixture.Component.CurrentTarget.Id);
}
回放回归:把问题“冻结成样本”
1. 基线文件策略
每次重大版本发布前,录制并固定以下样本:
baseline_1v1_duel_001.rplbaseline_2v2_teamfight_001.rplbaseline_boss_phase_001.rpl
每个样本记录预期指标:
- 总时长(帧数)
- 总伤害
- 暴击次数
- 目标切换次数
- 击杀时间点
2. 回归断言示例
[Test]
public void Replay_2v2_Baseline_Should_Match_KeyMetrics()
{
var data = ReplayLoaderUtil.Load("tests/Replay/Data/baseline_2v2_001.rpl");
var result = ReplayVerifier.Run(data);
Assert.AreEqual(5400, result.TotalFrames);
Assert.AreEqual(18320, result.TotalDamage);
Assert.AreEqual(47, result.CritCount);
Assert.AreEqual(26, result.TargetSwitchCount);
Assert.AreEqual(4210, result.FirstKillFrame);
}
性能烟测:防止“功能正确但性能崩”
[Test]
public void BattleLoop_10Minutes_Should_Keep_GC_And_AliveObjects_InBudget()
{
var sim = BattleSimulator.Create(seed: 20250115);
sim.RunFrames(60 * 60 * 10); // 10 分钟 @60fps
Assert.LessOrEqual(sim.Metrics.Gen0Collections, 40);
Assert.LessOrEqual(sim.Metrics.MaxAliveProjectiles, 256);
Assert.LessOrEqual(sim.Metrics.PeakPendingCommands, 512);
}
CI 执行建议
流水线最小步骤:
- 运行 L1 单测(< 1 分钟)。
- 运行关键回放样本(2~3 分钟)。
- 运行 1 个性能烟测(1~2 分钟)。
- 生成测试报告并归档失败回放。
建议失败即阻断合并,避免“先合再修”。
常见坑
坑 1:测试依赖真实时间
用了 DateTime.Now 或 Time.time 会导致偶发失败。应注入可控时钟。
坑 2:回放样本频繁被覆盖
基线应该是“签名资产”。只有评审通过才允许更新。
坑 3:断言只看最终 HP
中间链路错误可能被掩盖。应同时断言过程指标(暴击数、切换数、事件序列)。
本月作业
补齐你的第一版“战斗质量门禁”:
- 为命令/FSM/技能/Buff/伤害/仇恨各加至少 3 条单测。
- 固化 2 个回放基线文件,并写 2 条回放断言。
- 把测试命令接入本地一键脚本与 CI。
下一阶段开始进入 Unity 路线:把这套 C# Foundation 能力迁入 Unity 场景工程,构建真正可玩的 WebGL 小游戏基础架构。