Article

C# 实战课 13:战斗回放数据落盘(命令/伤害/仇恨三链合一)

系列定位:Unity 老版本兼容(.NET 3.5 / C# 4)+ 项目驱动。
本章目标:把前几章的运行时数据真正沉淀成“可回放文件”,用于线上问题复现、数值审计和自动化回归。

学习目标

完成本章后,你应该能做到:

  1. 定义稳定的回放文件结构(Header + FrameEvents + Footer)。
  2. 将命令、伤害、仇恨事件统一编码并落盘。
  3. 在离线环境加载回放并重放关键战斗过程。
  4. 通过校验和与版本号保证格式可演进。

为什么必须做“落盘回放”

仅有内存回放还不够:

  1. 线上问题无法带回本地复现。
  2. 测试回归没有“固定战斗样本”。
  3. 数值调整缺少可对比数据集。

目标是建立一个最小可用闭环:

  • 运行时记录 -> 写入文件 -> 本地回放 -> 验证结果一致。

文件格式设计

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 比二进制结构更稳妥;后续可引入紧凑二进制编码。

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);
        }
    }
}

与前面章节打通方式

  1. 命令系统:每次 CommandBus.Tick 后抓取 CommandRecord 追加到回放。
  2. 伤害管线DamagePipeline.Execute 返回结果后写入 Damage 事件。
  3. 仇恨系统:目标切换时写入 Threat 事件。
  4. 状态机ChangeState 时写入 StateTransition 事件。

这样一来,一个回放文件就能还原“输入 -> 结算 -> 决策”完整链路。

版本演进策略

建议从第一天就定规则:

  1. 只新增字段,不随意删改旧字段语义。
  2. Header 带 Version,Loader 按版本分支解析。
  3. 新事件类型必须保留旧解析兼容路径。
  4. 维护 ReplaySchema.md,写清每个版本变化。

验收清单

  1. 一场 2 分钟战斗可成功导出 .rpl 文件。
  2. 加载后重放,关键指标一致:击杀时间、总伤害、目标切换次数。
  3. 篡改文件任意字节后,CRC 校验失败并拒绝加载。
  4. 不同构建机器在相同回放文件下输出一致结果。

常见坑

坑 1:记录事件但不记录随机种子

会导致“动作一样、结果不一样”。必须记录并重放固定随机流。

坑 2:事件时间基准不统一

有的用 Time.time,有的用帧号,最后顺序错乱。建议统一按帧记录。

坑 3:回放路径复用生产对象池时没清理状态

会出现“脏数据继承”。回放前必须清空世界态与池对象状态。

本月作业

做一个“最小回放调试器”:

  1. 支持暂停、单帧前进、跳帧到指定时间。
  2. 右侧面板显示当前帧的命令/伤害/仇恨事件列表。
  3. 导出一次战斗对比报告(线上原始 vs 本地重放)。

下一章进入“资源与生命周期治理”,解决战斗长期运行下的内存与对象管理问题。