系列定位:Unity 老版本兼容(.NET 3.5 / C# 4 语法可落地)+ 项目驱动。
本章目标:把“玩家输入 -> 业务动作”改造成“输入采样 -> 命令 -> 执行器”,并支持录制与回放。
学习目标
完成本章后,你应该能做到:
- 解释为什么“输入逻辑直接改状态”会让系统难测、难回放、难同步。
- 实现一套轻量命令接口,支持排队执行、执行结果记录、失败重试。
- 在不依赖新语法的前提下,落地“命令录制 + 命令回放”流程。
- 把命令系统接入前面章节的 Foundation 小库(日志、断言、调度、对象池)。
场景与痛点
很多早期 Unity 项目里,代码会长这样:
if (Input.GetKeyDown(KeyCode.Space))
{
player.Jump();
}
if (Input.GetMouseButtonDown(0))
{
player.Fire();
}
看起来直接、简单,但随着需求增长会出现四个问题:
- 不可重放:线上出现 Bug 时,你拿不到“玩家在第 132 帧到底做了什么”。
- 不可测试:逻辑依赖
Input静态接口,单测很难注入假输入。 - 不可复用:AI、教学引导、回放系统都要重复“按键映射到动作”的代码。
- 难做网络同步:你最终会意识到,同步“输入命令”比同步“最终状态”更稳定。
命令系统的本质是:把动作描述对象化,让业务只认“命令”,不认“输入来源”。
设计约束(Unity 老版本兼容)
我们明确保持以下约束,避免后续迁移翻车:
- 不使用
record、Span<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/Y、WeaponId、随机种子)。
与 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。
本月作业
做一个“教学演示模式”:
- 录制一段高手操作。
- 新玩家可在场景里实时观看回放。
- 在 UI 面板显示当前回放命令序列号与动作名。
这个作业会直接复用下一阶段 Unity 章节中的 UI 与场景组织能力,因此建议本月就把命令系统接成独立模块,避免后续返工。