系列定位:Unity 老版本兼容(.NET 3.5 / C# 4)+ 项目驱动。
本章目标:把前几章的运行时数据真正沉淀成“可回放文件”,用于线上问题复现、数值审计和自动化回归。
学习目标
完成本章后,你应该能做到:
- 定义稳定的回放文件结构(Header + FrameEvents + Footer)。
- 将命令、伤害、仇恨事件统一编码并落盘。
- 在离线环境加载回放并重放关键战斗过程。
- 通过校验和与版本号保证格式可演进。
为什么必须做“落盘回放”
仅有内存回放还不够:
- 线上问题无法带回本地复现。
- 测试回归没有“固定战斗样本”。
- 数值调整缺少可对比数据集。
目标是建立一个最小可用闭环:
- 运行时记录 -> 写入文件 -> 本地回放 -> 验证结果一致。
文件格式设计
1. 结构总览
ReplayFile
Header
FrameEvent[]
Footer
2. Header
public struct ReplayHeader
{
public int Version;
public int MatchId;
public int RandomSeed;
public long StartUnixMs;
public int TickRate;
}
Version:格式版本,未来新增字段时用于兼容。RandomSeed:保证重放随机一致性。TickRate:重放时间基准(例如 30/60)。
3. 事件编码
public enum ReplayEventType
{
Command = 1,
Damage = 2,
Threat = 3,
StateTransition = 4
}
public struct ReplayEvent
{
public int Frame;
public ReplayEventType Type;
public string PayloadJson;
}
兼容旧版本时,PayloadJson 比二进制结构更稳妥;后续可引入紧凑二进制编码。
4. Footer
public struct ReplayFooter
{
public int EventCount;
public uint Crc32;
}
项目驱动:实现 ReplayRecorder
1. 记录器
public sealed class ReplayRecorder
{
private readonly List<ReplayEvent> _events = new List<ReplayEvent>(4096);
private ReplayHeader _header;
public void Begin(int version, int matchId, int randomSeed, int tickRate)
{
_header = new ReplayHeader
{
Version = version,
MatchId = matchId,
RandomSeed = randomSeed,
StartUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
TickRate = tickRate
};
_events.Clear();
}
public void RecordCommand(int frame, CommandRecord cmd)
{
var payload = "{\"seq\":" + cmd.Sequence + ",\"name\":\"" + cmd.Name + "\",\"ok\":" + (cmd.Success ? "true" : "false") + "}";
_events.Add(new ReplayEvent { Frame = frame, Type = ReplayEventType.Command, PayloadJson = payload });
}
public void RecordDamage(int frame, DamageResult damage)
{
var payload = "{\"hit\":" + (damage.IsHit ? "true" : "false") + ",\"crit\":" + (damage.IsCritical ? "true" : "false") + ",\"final\":" + damage.FinalDamage + "}";
_events.Add(new ReplayEvent { Frame = frame, Type = ReplayEventType.Damage, PayloadJson = payload });
}
public void RecordThreatSwitch(int frame, int ownerId, int fromId, int toId)
{
var payload = "{\"owner\":" + ownerId + ",\"from\":" + fromId + ",\"to\":" + toId + "}";
_events.Add(new ReplayEvent { Frame = frame, Type = ReplayEventType.Threat, PayloadJson = payload });
}
public void Save(string path)
{
var footer = new ReplayFooter
{
EventCount = _events.Count,
Crc32 = 0u
};
using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None))
using (var bw = new BinaryWriter(fs, Encoding.UTF8))
{
WriteHeader(bw, _header);
WriteEvents(bw, _events);
// 先写占位 Footer,之后回填 CRC
var footerPos = fs.Position;
WriteFooter(bw, footer);
bw.Flush();
fs.Position = 0;
var crc = Crc32Util.Compute(fs);
fs.Position = footerPos;
footer.Crc32 = crc;
WriteFooter(bw, footer);
}
FoundationLog.Info("Replay", "Saved path=" + path + " events=" + _events.Count);
}
private static void WriteHeader(BinaryWriter bw, ReplayHeader h)
{
bw.Write(0x55334443); // U3DC magic
bw.Write(h.Version);
bw.Write(h.MatchId);
bw.Write(h.RandomSeed);
bw.Write(h.StartUnixMs);
bw.Write(h.TickRate);
}
private static void WriteEvents(BinaryWriter bw, List<ReplayEvent> events)
{
bw.Write(events.Count);
for (var i = 0; i < events.Count; i++)
{
var e = events[i];
bw.Write(e.Frame);
bw.Write((int)e.Type);
bw.Write(e.PayloadJson ?? string.Empty);
}
}
private static void WriteFooter(BinaryWriter bw, ReplayFooter f)
{
bw.Write(f.EventCount);
bw.Write(f.Crc32);
}
}
加载与回放
1. 加载器
public sealed class ReplayLoader
{
public ReplayData Load(string path)
{
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read))
using (var br = new BinaryReader(fs, Encoding.UTF8))
{
var magic = br.ReadInt32();
if (magic != 0x55334443)
{
throw new InvalidDataException("Replay magic mismatch");
}
var header = new ReplayHeader
{
Version = br.ReadInt32(),
MatchId = br.ReadInt32(),
RandomSeed = br.ReadInt32(),
StartUnixMs = br.ReadInt64(),
TickRate = br.ReadInt32()
};
var count = br.ReadInt32();
var events = new List<ReplayEvent>(count);
for (var i = 0; i < count; i++)
{
var e = new ReplayEvent
{
Frame = br.ReadInt32(),
Type = (ReplayEventType)br.ReadInt32(),
PayloadJson = br.ReadString()
};
events.Add(e);
}
var footer = new ReplayFooter
{
EventCount = br.ReadInt32(),
Crc32 = br.ReadUInt32()
};
if (footer.EventCount != events.Count)
{
throw new InvalidDataException("Replay event count mismatch");
}
return new ReplayData(header, events, footer);
}
}
}
public sealed class ReplayData
{
public readonly ReplayHeader Header;
public readonly List<ReplayEvent> Events;
public readonly ReplayFooter Footer;
public ReplayData(ReplayHeader header, List<ReplayEvent> events, ReplayFooter footer)
{
Header = header;
Events = events;
Footer = footer;
}
}
2. 重放执行器
public sealed class ReplayRunner
{
private readonly ReplayData _data;
private int _index;
public ReplayRunner(ReplayData data)
{
_data = data;
_index = 0;
}
public void Tick(int frame, BattleWorld world)
{
while (_index < _data.Events.Count)
{
var e = _data.Events[_index];
if (e.Frame > frame)
{
break;
}
Dispatch(world, e);
_index++;
}
}
private static void Dispatch(BattleWorld world, ReplayEvent e)
{
if (e.Type == ReplayEventType.Command)
{
world.ReplayApplyCommand(e.PayloadJson);
}
else if (e.Type == ReplayEventType.Damage)
{
world.ReplayApplyDamage(e.PayloadJson);
}
else if (e.Type == ReplayEventType.Threat)
{
world.ReplayApplyThreat(e.PayloadJson);
}
else if (e.Type == ReplayEventType.StateTransition)
{
world.ReplayApplyStateTransition(e.PayloadJson);
}
}
}
与前面章节打通方式
- 命令系统:每次
CommandBus.Tick后抓取CommandRecord追加到回放。 - 伤害管线:
DamagePipeline.Execute返回结果后写入Damage事件。 - 仇恨系统:目标切换时写入
Threat事件。 - 状态机:
ChangeState时写入StateTransition事件。
这样一来,一个回放文件就能还原“输入 -> 结算 -> 决策”完整链路。
版本演进策略
建议从第一天就定规则:
- 只新增字段,不随意删改旧字段语义。
- Header 带
Version,Loader 按版本分支解析。 - 新事件类型必须保留旧解析兼容路径。
- 维护
ReplaySchema.md,写清每个版本变化。
验收清单
- 一场 2 分钟战斗可成功导出
.rpl文件。 - 加载后重放,关键指标一致:击杀时间、总伤害、目标切换次数。
- 篡改文件任意字节后,CRC 校验失败并拒绝加载。
- 不同构建机器在相同回放文件下输出一致结果。
常见坑
坑 1:记录事件但不记录随机种子
会导致“动作一样、结果不一样”。必须记录并重放固定随机流。
坑 2:事件时间基准不统一
有的用 Time.time,有的用帧号,最后顺序错乱。建议统一按帧记录。
坑 3:回放路径复用生产对象池时没清理状态
会出现“脏数据继承”。回放前必须清空世界态与池对象状态。
本月作业
做一个“最小回放调试器”:
- 支持暂停、单帧前进、跳帧到指定时间。
- 右侧面板显示当前帧的命令/伤害/仇恨事件列表。
- 导出一次战斗对比报告(线上原始 vs 本地重放)。
下一章进入“资源与生命周期治理”,解决战斗长期运行下的内存与对象管理问题。