玩法事件与脚本
全库目录221
本文目录12
一次拾取发生以后,库存、音效和任务系统都要知道,但它们不应各自再执行一次拾取规则。玩法层先由明确的处理者改变状态,再广播结果;脚本与可视化图组合这条流程,事件队列则决定结果在什么时候对其他系统可见。
事件系统
发布与订阅 让发布者不直接依赖订阅者。事件至少需要定义:
- 事件类型与负载;
- 立即派发、Tick前或Tick后等处理时机;
- 订阅者生命周期和取消订阅规则;
- 同一帧重入、顺序和异常处理;
- 队列容量和过载策略。
立即派发的事件延迟低,但回调可能再次修改当前容器。排队事件更容易控制阶段,却会延迟状态可见时间。重要状态不应只存在于“曾经发过一个事件”里;事件通知变化,权威状态仍由明确对象保存。
事件与命令的区分
事件表达已经发生的事实,命令表达希望发生的请求。两者反向流动:命令从输入与 AI 流向规则系统,事件从规则系统流向表现与反馈。
混淆两者的典型后果是命令被当成事件广播出去,多个订阅者各自响应一次同一个意图——一次拾取触发了多次音效与多次加分。命令应当有唯一处理者,事件可以有任意数量订阅者。命名上能直接区分:命令用祈使(RequestPickup),事件用过去时(ItemPicked)。
事件路由还需要定义优先级与顺序。同一帧内的多个事件如果互相影响(受伤与死亡),处理顺序必须确定——按订阅顺序、按优先级排序或按阶段分批,选哪种都行,但必须写进约定,不能依赖注册顺序的巧合。
事件负载尽量使用值或稳定 ID。长期保存对象强引用会阻止卸载,裸指针又可能悬空。弱引用、句柄或复制必要字段的快照 更容易处理场景切换。
事实负载与订阅寿命
拾取事件写入物品ID、拾取者ID、数量和发生Tick,订阅者据此播放反馈。若只保存一个可变对象引用,排队到下一阶段时对象可能已经被复用或销毁,读到的便不是事件发生时的事实。必要字段复制成值,需追踪对象时使用ID与代数。
代码在派发每个事件前复制它的订阅列表,因此该事件派发中注销的处理器仍可能存在于当前快照里。本页约定当前事件的快照照常执行;同一轮中后续事件会重新读取订阅表,已能看到注销结果。若希望当前事件内立刻失效,回调入口还需检查订阅令牌状态。场景销毁必须等待或禁用这类回调,不能只从订阅表删除条目就释放其捕获对象。
单线程队列解决重入顺序,不保证跨线程发布安全。后台事件先进入线程安全入口,再在固定玩法阶段合并,并用明确键排序需要确定性的数据。事件洪峰达到容量时,按类型决定合并、拒绝或延迟,伤害与库存事实不能像鼠标移动提示一样静默丢弃。
脚本系统
脚本让玩法代码脱离引擎核心构建周期。运行时可能使用解释器、字节码 VM、JIT 或 AOT。选择要同时考虑平台限制、调用开销、调试、热更新和安全边界。
引擎绑定层负责类型转换、对象句柄、错误传播和生命周期。跨语言调用比语言内部调用昂贵,适合批量接口。脚本不能假设引擎对象和 GC 对象共享同一生命周期。
热更新要定义可替换边界。代码能替换不代表旧状态能自动迁移。序列化版本、字段默认值、正在执行的协程和网络协议都需要兼容策略。
可视化脚本
Blueprint 一类系统把节点图编译或解释成执行逻辑。节点图适合关卡事件、任务流和内容配置,但大型图同样会产生隐藏状态与连接复杂度。
节点应有稳定引脚类型、清楚的副作用和可搜索文档。编译器可以检查断线、类型错误、无入口节点和循环依赖。调试需要断点、单步、当前节点高亮、变量观察和调用栈,而不只是“图能运行”。
底层高频循环和通用算法更适合代码节点或原生模块。可视化脚本负责组合,不等于所有逻辑都必须节点化。
3C
脚本调用角色、控制与相机接口时,先按3C的职责限定可修改状态。3C指角色(Character)、控制(Control)与相机(Camera):
- Character 保存移动能力、朝向、动作和碰撞状态;
- 控制把玩家或 AI 意图转为统一命令;
- Camera 根据目标、遮挡和玩法模式生成视图。
相机 Spring Arm、碰撞避让、FOV、Aim Assist 和 Camera Shake 属于可组合策略。相机不能直接篡改角色权威状态。玩家控制和 AI 控制复用同一意图接口,能减少“只有玩家路径能用”的功能分叉。
组织与验证
一项玩法功能可以沿“输入或 AI 意图 → 请求 → 状态变化 → 事件 → 表现”画出时序图。若某个状态有多个写入者,先收口所有权。测试时记录事件队列、脚本异常、状态转换和表现触发,才能区分规则错误与表现错误。
延迟派发实现
玩法阶段先把事件写入当前 Tick 的队列,阶段结束后再派发,避免回调重入修改正在遍历的系统状态:
private Queue<object> m_pending = new Queue<object>();
private bool m_flushing;
/// <summary>Queues an event for the next dispatch round.</summary>
public void Publish<T>(in T message) where T : struct
{
m_pending.Enqueue(message);
}
/// <summary>Dispatches the events present at the start of this round.</summary>
public void Flush()
{
if (m_flushing)
{
return;
}
var current = m_pending;
m_pending = new Queue<object>();
m_flushing = true;
try
{
while (current.TryDequeue(out var message))
{
var subscribers = handlers.For(message.GetType()).ToArray();
foreach (var handler in subscribers)
{
try
{
handler.Invoke(message);
}
catch (Exception exception)
{
LogHandlerFailure(exception);
}
}
}
}
finally
{
m_flushing = false;
}
}事件类型应表达已经发生的事实,命令则表达请求,两者不要共用无语义字符串。这是依赖队列、处理器表与日志接口的 C# 机制片段,面向单线程玩法阶段。派发前交换队列,回调新增事件留到下一次 Flush;递归调用 Flush 会直接返回。处理器列表按事件取快照,并记录异常后继续;这里要求日志函数不抛异常,处理器表查询也不能使当前队列半途丢失。示例用 object 做最小类型擦除,会对值类型产生装箱;高频路径可为每种事件使用泛型环形队列或预分配的类型擦除存储。需要确定性回放时,队列顺序、Tick 和订阅顺序必须稳定。验证可让处理器继续发布事件,确认新事件进入下一轮,而不是递归调用导致栈增长。
本机通过 verify_event_queue.py 从正文提取片段,补最小处理器表与日志宿主,在.NET SDK 9.0.319的Release配置中编译运行。4项检查覆盖下一轮派发、重入保护、处理器异常隔离和按事件建立订阅快照;没有接入Unity玩法或多线程发布。
验证方法
- 人为让一个订阅者抛异常,确认其余订阅者与事件队列不受影响。
- 订阅者在回调中再发布事件,确认进入下一轮队列而非递归执行。
- 事件高频洪峰时观察队列容量与丢弃策略,而不是只看平均帧。
- 场景切换时检查订阅是否正确注销,切换后不应再收到旧场景的事件。
- 用录制的事件序列回放,确认相同输入得到相同的状态转移序列。
实现与证据
2026-10-22运行 python 知识库/examples/revision_16_21/verify_event_queue.py,当前正文片段在.NET 9.0.319中4项检查通过;多线程与场景卸载未由该实验覆盖。
相关主题
参考资料
- Game Programming Patterns:Event Queue:排队事件、时序与负载寿命。
- Microsoft C# Events:订阅与发布边界。