Article

C# 实战课 07:命令系统与操作回放(把输入层和业务层彻底解耦)

系列定位:Unity 老版本兼容(.NET 3.5 / C# 4 语法可落地)+ 项目驱动。
本章目标:把“玩家输入 -> 业务动作”改造成“输入采样 -> 命令 -> 执行器”,并支持录制与回放。

学习目标

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

  1. 解释为什么“输入逻辑直接改状态”会让系统难测、难回放、难同步。
  2. 实现一套轻量命令接口,支持排队执行、执行结果记录、失败重试。
  3. 在不依赖新语法的前提下,落地“命令录制 + 命令回放”流程。
  4. 把命令系统接入前面章节的 Foundation 小库(日志、断言、调度、对象池)。

场景与痛点

很多早期 Unity 项目里,代码会长这样:

if (Input.GetKeyDown(KeyCode.Space))
{
    player.Jump();
}

if (Input.GetMouseButtonDown(0))
{
    player.Fire();
}

看起来直接、简单,但随着需求增长会出现四个问题:

  1. 不可重放:线上出现 Bug 时,你拿不到“玩家在第 132 帧到底做了什么”。
  2. 不可测试:逻辑依赖 Input 静态接口,单测很难注入假输入。
  3. 不可复用:AI、教学引导、回放系统都要重复“按键映射到动作”的代码。
  4. 难做网络同步:你最终会意识到,同步“输入命令”比同步“最终状态”更稳定。

命令系统的本质是:把动作描述对象化,让业务只认“命令”,不认“输入来源”。

设计约束(Unity 老版本兼容)

我们明确保持以下约束,避免后续迁移翻车:

  • 不使用 recordSpan<T>ValueTask 等新特性。
  • 避免高频装箱和闭包分配。
  • 所有命令对象优先来自对象池,减少 GC 抖动。
  • 命令执行必须可追踪(日志 + 序列号 + 时间戳)。

核心模型设计

1. 命令接口

public interface ICommand
{
    int Sequence { get; set; }
    float Timestamp { get; set; }
    string Name { get; }
    bool Execute(CommandContext context);
    void Reset();
}

关键点:

  • Sequence:全局递增序列号,便于排查和回放校验。
  • Timestamp:输入采样时刻(秒)。
  • Execute 返回 bool:用于区分成功/失败,驱动重试和告警。
  • Reset:配合对象池回收,避免残留状态污染下一次使用。

2. 执行上下文

public sealed class CommandContext
{
    public readonly PlayerService Player;
    public readonly WeaponService Weapon;
    public readonly Scheduler Scheduler;

    public CommandContext(PlayerService player, WeaponService weapon, Scheduler scheduler)
    {
        Player = player;
        Weapon = weapon;
        Scheduler = scheduler;
    }
}

上下文不要做成“万能服务定位器”。你传什么,就代表当前子系统允许命令操作什么。

3. 命令总线(队列 + 录制)

public sealed class CommandBus
{
    private readonly Queue<ICommand> _queue = new Queue<ICommand>(128);
    private readonly List<CommandRecord> _records = new List<CommandRecord>(512);
    private int _sequence;

    public int PendingCount { get { return _queue.Count; } }

    public void Enqueue(ICommand command, float timestamp)
    {
        FoundationAssert.NotNull(command, "CommandBus.Enqueue command");
        command.Sequence = ++_sequence;
        command.Timestamp = timestamp;
        _queue.Enqueue(command);
    }

    public void Tick(CommandContext context)
    {
        while (_queue.Count > 0)
        {
            var cmd = _queue.Dequeue();
            var ok = cmd.Execute(context);

            _records.Add(new CommandRecord(cmd.Sequence, cmd.Timestamp, cmd.Name, ok));
            FoundationLog.Info("[Command] seq=" + cmd.Sequence + " name=" + cmd.Name + " ok=" + ok);
        }
    }

    public List<CommandRecord> SnapshotRecords()
    {
        return new List<CommandRecord>(_records);
    }

    public void ClearRecords()
    {
        _records.Clear();
    }
}

public struct CommandRecord
{
    public readonly int Sequence;
    public readonly float Timestamp;
    public readonly string Name;
    public readonly bool Success;

    public CommandRecord(int sequence, float timestamp, string name, bool success)
    {
        Sequence = sequence;
        Timestamp = timestamp;
        Name = name;
        Success = success;
    }
}

项目驱动:做一个“可回放射击训练场”

目标很具体:

  • 玩家按键触发移动/开火命令。
  • 每帧将命令记录到内存(后续可落盘)。
  • 点击“回放”后,禁用真实输入,按记录重放同一段动作。

第一步:定义具体命令

public sealed class MoveCommand : ICommand
{
    public int Sequence { get; set; }
    public float Timestamp { get; set; }
    public string Name { get { return "Move"; } }

    public float X;
    public float Y;

    public bool Execute(CommandContext context)
    {
        return context.Player.Move(X, Y);
    }

    public void Reset()
    {
        Sequence = 0;
        Timestamp = 0f;
        X = 0f;
        Y = 0f;
    }
}

public sealed class FireCommand : ICommand
{
    public int Sequence { get; set; }
    public float Timestamp { get; set; }
    public string Name { get { return "Fire"; } }

    public int WeaponId;

    public bool Execute(CommandContext context)
    {
        return context.Weapon.Fire(WeaponId);
    }

    public void Reset()
    {
        Sequence = 0;
        Timestamp = 0f;
        WeaponId = 0;
    }
}

第二步:接入对象池(第 05 章成果)

public sealed class CommandFactory
{
    private readonly ObjectPool<MoveCommand> _movePool;
    private readonly ObjectPool<FireCommand> _firePool;

    public CommandFactory()
    {
        _movePool = new ObjectPool<MoveCommand>(64, delegate { return new MoveCommand(); }, delegate(MoveCommand c) { c.Reset(); });
        _firePool = new ObjectPool<FireCommand>(32, delegate { return new FireCommand(); }, delegate(FireCommand c) { c.Reset(); });
    }

    public MoveCommand GetMove(float x, float y)
    {
        var cmd = _movePool.Get();
        cmd.X = x;
        cmd.Y = y;
        return cmd;
    }

    public FireCommand GetFire(int weaponId)
    {
        var cmd = _firePool.Get();
        cmd.WeaponId = weaponId;
        return cmd;
    }

    public void Recycle(ICommand command)
    {
        var move = command as MoveCommand;
        if (move != null)
        {
            _movePool.Release(move);
            return;
        }

        var fire = command as FireCommand;
        if (fire != null)
        {
            _firePool.Release(fire);
        }
    }
}

第三步:输入采样层(只负责采样,不碰业务)

public sealed class InputSampler
{
    private readonly CommandFactory _factory;
    private readonly CommandBus _bus;

    public InputSampler(CommandFactory factory, CommandBus bus)
    {
        _factory = factory;
        _bus = bus;
    }

    public void Sample(float now)
    {
        var x = Input.GetAxisRaw("Horizontal");
        var y = Input.GetAxisRaw("Vertical");

        if (x != 0f || y != 0f)
        {
            _bus.Enqueue(_factory.GetMove(x, y), now);
        }

        if (Input.GetMouseButtonDown(0))
        {
            _bus.Enqueue(_factory.GetFire(1), now);
        }
    }
}

这里最重要的变化是:InputSampler 不知道 PlayerService,只生产命令。

第四步:录制与回放

public sealed class ReplayRunner
{
    private readonly List<CommandRecord> _records;
    private int _index;

    public ReplayRunner(List<CommandRecord> records)
    {
        _records = records;
        _index = 0;
    }

    public bool IsFinished
    {
        get { return _index >= _records.Count; }
    }

    public void Tick(float now, CommandBus bus, CommandFactory factory)
    {
        while (_index < _records.Count)
        {
            var rec = _records[_index];
            if (rec.Timestamp > now)
            {
                break;
            }

            if (rec.Name == "Move")
            {
                // 生产环境应记录参数,这里演示流程。
                bus.Enqueue(factory.GetMove(1f, 0f), rec.Timestamp);
            }
            else if (rec.Name == "Fire")
            {
                bus.Enqueue(factory.GetFire(1), rec.Timestamp);
            }

            _index++;
        }
    }
}

提示:要做到“像素级回放一致”,必须记录参数(如 X/YWeaponId、随机种子)。

与 Foundation 小库对齐

本章新增一个 Commands 模块,目录建议:

foundation/
  Runtime/
    Commands/
      ICommand.cs
      CommandBus.cs
      CommandContext.cs
      CommandRecord.cs
      CommandFactory.cs

与前四章的衔接关系:

  • 日志:通过 FoundationLog 统一输出命令追踪。
  • 断言:通过 FoundationAssert 保护命令入队合法性。
  • 调度:复杂命令(延时施放、连击)走 Scheduler
  • 对象池:命令实例与临时数据都用池化分配。

常见坑与规避策略

坑 1:命令里直接读取 Input

这会把“可回放”能力直接破坏。命令必须是纯动作描述,不依赖设备状态。

坑 2:回收后继续持有命令引用

对象池一旦 Release,外部不得再读取命令字段。建议在 DEBUG 模式加入“已回收标记”断言。

坑 3:录制时只记事件名,不记参数

回放结果会和现场严重偏离。最少要记录:命令名、参数、时间戳、随机种子。

坑 4:混用“实时输入时间”和“游戏逻辑时间”

应统一使用同一时钟(建议逻辑时钟),否则慢机与快机回放不一致。

验收清单

完成本章后,用以下标准自检:

  • 可以关闭真实输入,仅靠录制数据驱动角色动作。
  • 同一段录制回放 3 次,角色关键状态一致(位置、血量、开火次数)。
  • Profiler 中命令处理路径无明显 GC 峰值。
  • 命令执行失败会落日志,且能定位到 Sequence

本月作业

做一个“教学演示模式”:

  1. 录制一段高手操作。
  2. 新玩家可在场景里实时观看回放。
  3. 在 UI 面板显示当前回放命令序列号与动作名。

这个作业会直接复用下一阶段 Unity 章节中的 UI 与场景组织能力,因此建议本月就把命令系统接成独立模块,避免后续返工。