这篇是「如何制作一款开放世界游戏」系列实战篇之一,承接角色篇(《在 UE5 上重建一座塞满人的城市》上下两篇)。角色篇讨论「海量角色」一侧的运行时组织——世界托管、相位调度、人口与感知流水线;本篇转向角色篇有意留白的另一半:由驾驶员控制的车辆运行时。 材料同样来自对一款成熟商业开放世界游戏引擎与客户端代码的长期源码级逆向阅读(约 700+ 篇源码级笔记),类名、函数名、阶段划分都在源码里逐处核对过,发布时按惯例做匿名化处理。
车辆主题按「层」切成上下两篇:本篇(上)讲调度与决策——到「产生驾驶意图」为止:驾驶智能归属、座位关系权威、多来源驾驶指令仲裁、驾驶授权与行为级 LOD。下篇讲行为、表现与物理——执行驾驶意图:驾驶执行栈如何把目标转换为控制量、进出车动作链、dummy 车的低成本运动,以及意图如何接入真实物理。
和角色篇一样,这一篇的 UE5 落法不是概念性推演:我们有一个正在开发的 UE5 原型工程(下称 VirtualWorld / VW),它刻意不把车辆关系层交给现成的批处理框架,而是从零构建一套「世界托管实体、按相位推进」的运行时。车辆是这个原型中实现最为完整的区域之一——座位关系、驾驶指令仲裁、驾驶权一致性、行为级 LOD、操控校验都有真实代码,且被一套规模可观的自动化测试覆盖:截至本文写作,整个运行时插件有 769 条自动化测试(约 7 万行测试代码),其中名字含「Seat(座位)」的 173 条、含「Reservation(预约)」的 74 条、含「DriverCommand(驾驶指令)」的 32 条。测试数量不是系统复杂度的直接指标,它反映的是状态空间被显式建模并持续验证的范围。 所以本篇每章的第三段「UE5 实现状态」,讲的多数是原型里已经落地并被测试覆盖的内容,能对照真实实现来讲,而不是概念性规划。
写法上每个子系统走同一条三段线索:先讲原版机制(源码级),再讲为什么这套语义需要自建关系边界、哪些部分可复用,最后落到原型实现与规划状态——哪些做完了、哪些还是骨架、哪些是已知缺口,都如实说。
诚实边界:我读透的是「车辆运行时如何组织、所有权如何切分」;尚未覆盖具体驾驶执行器的算法内部(各车型的巡航/追击/避让几何、寻路内核、junction 决策算法)——执行侧的设计在下篇展开,涉及算法内核的部分一律标注「盲区」。
UE5 实现细节在后续落地篇:本篇(与本系列所有方案篇)的定位是设计与迁移决策——原版为什么这样组织、哪些机制自建、哪些机制复用、职责边界如何划分。每篇方案篇都对应一篇落地篇,讨论 UE5 工程中的运行方式、代码走读、引擎子系统接入与验证结果。
引言:驾驶员在车辆运行时中的归属关系
角色篇的引言讨论过「哪个组件决定这一帧生成谁、删掉谁」。这一篇转向一个同样基础、但更具结构性的问题:
一辆由 NPC 驾驶的车辆,其驾驶智能应由车辆实体持有,还是由驾驶员实体持有?
这个问题并非措辞之争,而是车辆运行时的关键约束。所有权判断一旦错误,后续结构都会产生隐患:
- 如果驾驶智能归司机(ped)——司机离车后,车辆的驾驶循环将与其任务树继续耦合;司机任务发生切换时,车辆控制也可能失去明确所有者。联机状态下,司机还可能位于另一台机器,使车辆控制权缺少稳定归属。
- 如果驾驶智能归车辆——一辆刚生成、尚无乘员的空车为什么已经具有待命的驾驶智能?驾驶员发生切换时,新驾驶员如何接管同一套驾驶状态,而不是重新建立运行时?
进一步还会出现一组关联问题:
- 上百辆车同时运行时,每辆车的控制权可能同时来自「玩家输入」「AI 驾驶」「脚本强制」等来源——当前更新周期应接受哪一来源?
- NPC 从决定使用某个座位到完成占用之间存在时间间隔——系统如何避免重复占用,以及如何避免出现「座位显示有人、实际无人」的不一致状态?目标座位已被占用时,由哪个组件选择替代座位或终止操作?记录不一致时,哪一侧拥有权威并负责修复?
- 对于距离较远的车辆,系统不必执行完整物理和驾驶 AI。哪个层级负责决定其行为降级?
这一篇围绕这些问题展开。贯穿全篇的主线,角色篇已经提出,本篇将以车辆为例进一步展开:系统不会混淆状态的持有者与状态的观察者。 车拥有持久的驾驶循环,ped 只下发驾驶意图;座位关系有明确的权威方,ped 侧只是非权威快照;驾驶指令有明确的仲裁和归属,每一次驾驶授权都留痕并可对账。职责即所有权——这是大规模车辆运行保持独立性的基础。
先给出答案,再逐章展开:驾驶 AI 归车,ped 只下发意图。 后文将说明这一判断如何落实为完整的运行时结构。本篇多数章节并非停留在设计图层面,而是可以逐函数对照并由数百条测试验证的实现。
本篇的路线是这样的:先建立持久驾驶智能(车辆拥有控制循环,ped 提交意图,第一章);然后回到 ped 与车辆的交界,讨论座位关系与预约(座位关系的权威归属、时延占位的一致性,第二章)、以及它在联机下如何通过网络克隆与迁移保持一致(第三章);接着讨论本篇最能体现所有权约束的驾驶权一致性(驾驶授权与实际占用的一致性,第四章);第五章把前面几章的成果汇成交通就绪门的单车视角(门链的主讲在交通篇);第六章讨论行为级 LOD 的决策侧(车辆行为降级的决策归属);第七章给出迁移取舍的实现清单。
一个边界说明:本篇讲到「产生驾驶意图」为止——驾驶授权、关系建立与行为降级决策。而将意图转化为执行的另一部分——驾驶执行栈(从选路到控制量生成)、进出车动作链、dummy 车辆的运动学预览、意图与真实物理的衔接——放在下篇专门讨论。更高一层的多车辆交通协同(路网、路口、信号灯、派遣)属于交通篇。
先给一个能概括本篇结构的数字:仅本篇涉及的座位管理、关系操作、对账快照、修复、驾驶授权、指令接受、归属一致性、预约管理、意图桥、意图解析这几族返回状态,枚举值加起来上百个——没有一处用裸布尔回答「行不行」。这不是过度设计,而是为了让大规模车辆运行中的异常能够被快速定位,并为每个拒绝路径提供可验证的原因。本篇③段将逐层说明这些枚举背后的门链。
先用一张总图固定这篇文章的所有权边界:谁提出意图、谁负责仲裁、谁拥有关系状态、谁只保存非权威快照。后文六章都只是沿着这张图展开不同关系和生命周期。

一件需要先说明的事:这几章里,原型的落地程度并不均匀。 有的章原型已有完整实现可对照——第二章(座位关系)和第四章(驾驶权一致性)是全篇的重心,③段会把意图桥、解析器、权威操作、预约策略、对账修复的函数体逐层拆开讲;第一、五、六章的③段是「骨架已立、可观测、行为待填」的中间态;而第三章(网络克隆迁移)原版证据充分、原型却几乎是空的——对它,第三段会诚实地讲成「迁移地图」,只标出「哪些输出口和基础层已经就位」,其余如实说是规划。全篇不会把这几种状态混为一谈,每章③段第一句就标明自己属于哪种。
一、持久驾驶智能:车辆持有控制循环,ped 提交意图
本章讨论的问题:车辆驾驶智能的所有权应如何定义?为什么由「车辆」而非「驾驶员」持有该智能,是大规模车辆运行与联机接管的前提?
① 原版机制
从源码呈现出的结构是:车辆运行时持有一个持久的驾驶智能对象(下称 VehicleIntelligence)。它在车被初始化时就创建出来,直接挂在车身上,并立刻播下一个默认任务——「无司机」。也就是说,一辆刚生成、尚无乘员的空车,已经有一套驾驶智能在待命了,只不过它当前的任务是「没司机、别动」。这里描述的是源码中可观察到的组织结构,不是对所有车辆系统的唯一实现方式。
这个智能对象不是为某次追逐临时创建的助手,而是与车同生命周期的长驻所有者。工厂还会按车型特化它:普通地面车/船一套、直升机一套、飞机一套。
关键的证据是驾驶更新的调用路径:车的 ProcessIntelligence() 调用的是车自己持有的智能对象的 Process()。司机 ped 并不直接拥有整个驾驶更新循环。 三者的关系是:
- 车拥有那个持久的驾驶智能对象;
- 那个智能对象拥有车侧的任务管理器和各更新相位;
- ped 的任务只是向这个所有者提交驾驶任务。
而这个驾驶智能远不止「当前那个驾驶任务」那么简单。它是一个持久的车侧 AI 状态与服务中枢,稳定地拥有:车侧任务管理器、附近 ped/车/物件的扫描器、转向/刹车/变道/避让/跟车/junction/警笛反应的分摊更新、假乘客响应的事件组、路网节点缓存、junction 与红绿灯状态、卡住检测、任务处理后的 dummy/superdummy 转换策略……它的 Process() 是一条多相位流水线:重置帧标志 → 更新水面/警笛/威胁车状态 → 处理船只避让 → 早期处理网络克隆 → 车型预更新 → 附近实体扫描 → 事件处理 → 处理车侧任务管理器 → 任务后收尾。
那 ped 侧负责什么?ped 侧有一座桥(下称 ControlVehicle 桥),它是 ped 运行时和车运行时之间最清晰的接口。这座桥的状态机做四件稳定的事:确保 ped 在目标车里、让车加入道路系统、给 ped 装一个车内子任务、把一个驾驶任务复制并装进车的智能任务树。它在进入驾驶态时明确地两边都做:给 ped 侧装子任务、给车侧智能装驾驶任务。
于是整个控制拆分十分清楚:ped 侧的包装任务负责「乘员/司机编排」和「决定向车辆提交哪个驾驶任务」;车侧的智能拥有持久运行时、任务管理器、扫描、事件、路网缓存、以及把通用任务标识翻译成具体驾驶任务的工厂;具体的巡航/goto/追击/警察行为算法,在更下层的驾驶执行器里。
还有一个不该被忽略的点:网络克隆是这一层的一等公民,不是事后附加的外层。 驾驶智能区分本地车和网络克隆——对克隆车,它跳过普通本地控制,转而依赖附近实体扫描、路网/junction 刷新、以及从网络对象读来的克隆 AI 任务数据。ped 侧那座桥同样是网络接管层,能把驾驶任务在克隆/本地迁移时重新水化出来。网络感知是内建进车侧所有者的,不是在外面包一层。
这一章的意义:让「车」而不是「司机」拥有驾驶循环,是一个反直觉但决定性的所有权选择。它让空车有待命智能、让换司机不必重建状态、让联机时车的控制权有明确归属。ped 只下发意图、车执行任务——这条拆分是后面所有章节(座位、驾驶权、LOD)能立住的前提。
② 为什么不套 Mass 这类现成方案
「车拥有一个持久的、带任务管理器和大量服务状态的智能对象」——这句话本身就和 Mass 的模型正面冲突。
Mass 更适合表达数据导向、批处理友好的实体表示。但车侧驾驶智能是一个长驻的、有大量内部状态和子服务的对象:它有自己的任务管理器(一棵会 push/pop 的任务树)、有路网缓存、有 junction 状态、有卡住检测的历史、有本地 vs 克隆的分支。这些是「一个有身份、有生命周期、有内部状态机的对象」,不能直接等同为一组可独立批处理的数据。
更关键的是所有权关系:ped 向车辆提交任务、车辆持有任务、任务被复制进车辆任务树、克隆时再重新水化——这是一张对象间的关系图,不是数据表。若直接使用 ECS 表达,就需要把这套关系拆成大量 tag Fragment 与查找表,或者在 ECS 外部另建对象图;无论哪种方式,都仍需显式维护关系边界。
一句话:驾驶智能是「有状态、有身份、具有所有权和网络接管语义的持久对象」,不能直接由同质数据的批处理表示替代。 对于纯装饰、无个体驾驶智能的远景车流,结论可能不同;但具备驾驶员、接管能力与网络身份的车辆需要保留这层对象边界。
③ 原型实现与规划状态
这条所有权拆分在原型里已经落地成真实代码。 FVWVehicle 拥有自己的车侧控制循环,和 ped 侧是分开的:
- 车侧有
DoProcessControl(Context)和ProcessIntelligence(Context)两个可观测的边界——对应原版「车的ProcessControl调DoProcessControl、再调车自己的ProcessIntelligence」。读真实实现,这条调用链是:ProcessControl记控制计数与时间片 → 调DoProcessControl→ 后者再记一层自己的计数 → 先跑UpdateKinematicPreview(运动学预览,下篇详解)→ 再跑ProcessIntelligence。也就是说,「预览推进」和「智能处理」都挂在控制相位里,每次控制更新各跑一次,顺序固定。原型甚至把「本帧是第几次控制、上次控制在哪个时间片、上次 delta 多少」都记进了FVWVehicleState的计数器(ControlProcessCount / DoProcessControlCount / IntelligenceProcessCount / LastControlTimeSlice …),方便验证调用时机。 - 物理相位同样带观测分流:
ProcessPhysics先递增PhysicsProcessCount,再看基类物理步的结果——被跳过(Skipped)就计入SkippedPhysicsCount,真正跑了就计入DomainPhysicsStepCount。「物理这一帧有没有真的跑」不是猜的,是分流计数出来的——这和角色篇讲的「物理步结果记进统计」是同一条观测纪律在车辆侧的延续。 - 驾驶指令与来源做成了一等状态:
FVWVehicleControlCommand(油门/刹车/转向/手刹/倒车意图)+EVWVehicleCommandSourceType { Player, AI, Script, Fallback }。这正是「车执行,指令来源可以是玩家/AI/脚本/回退路径」的落地——车辆只执行当前获得授权的指令,控制来源通过所有权状态记录。 - ped 侧提交意图走的是角色篇讲过的任务槽 + 一套「进出车 step」(Layer4 的
TaskEnterStep/TaskExitStep),把 ped 的一个任务槽转成「进车意图」,再由座位解析器落到座位——对应原版 ControlVehicle 桥「ped 向车辆提交任务」。这里的「意图」不是一次函数调用,而是一个带生命周期的状态对象(FVWPedVehicleIntentState:意图类型、目标车、目标座、请求序列号、状态机Requested → Completed / Rejected)——ped 提出意图后,由解析器在合适的时机消费它、把结果写回意图状态。「提出」和「兑现」被拆成两个时刻,中间隔着一次可拒绝的解析(第二章末尾详述)。
「任务槽 → 意图」这一转换过程本身也是一个显式桥接层:FVWPedVehicleTaskIntentBridge::RequestVehicleIntentFromTask。它的头注释把边界明确限定为——「Explicit semantic bridge only. It does not execute tasks, call the seat resolver, or mutate task lifecycle」(只做语义桥:不执行任务、不调用座位解析器、不改任务生命周期)。它做的唯一一件事,是把「某棵任务树上某个优先级的任务想上/下某辆车」翻译成一条意图请求,而且要先过五道门:任务槽中存在有效任务(MissingTask)→ 任务的生命周期状态得允许(DisallowedTaskLifecycle)→ 任务类型非空 → 目标车句柄有效 → 意图类型合法——全过才置 Requested。返回的结果照例是审计记录:来源任务的全部出处(哪棵树、什么优先级、什么类型、谁拥有、什么生命周期态、是不是脚本任务、脚本命令和阶段)连同新意图的序列号一起带回。于是整条链是:任务槽(角色篇的产物)→ 意图桥(只翻译,五道门)→ 意图状态(Requested)→ 座位解析器(消费、可拒绝、可回退)→ 意图终态(Completed/Rejected)→ 再由一座反向的生命周期桥把意图终态回写成任务槽的完成或中止——反向桥按测试名覆盖了进车完成、出车完成、中止、序列号过期、身份过期等情形。每一段只有一个职责,每一段的拒绝都有名字。

诚实边界:原型目前把「车拥有可观测的控制/智能边界 + 指令来源 + LOD 观测」立住了,但车侧智能里那些具体服务(附近扫描、路网缓存、junction、卡住检测、驾驶任务工厂)还没实现——ProcessIntelligence 的函数体目前只有四行:递增智能处理计数、记录本次 delta、记录时间片、递增时间片更新计数。「智能」目前只有可精确观测的接口骨架:调用时机、调用频率、每次间隔都可断言,具体行为尚未实现。这个边界说明了「车拥有驾驶循环」这一所有权结论目前落实到哪一层。也就是说,「车拥有驾驶循环」这个所有权骨架是真实的,循环中的具体行为尚未填充。 网络克隆分支同理,属于规划项。原型的实施顺序是先固定所有权和调用边界,再实现具体行为。

二、座位关系与预约:座位关系的权威归属
本章讨论的问题:NPC 从选择座位到建立实际占用之间存在时延。系统如何避免重复占用?座位关系由哪个组件维护权威状态?如何避免出现「座位显示有人、实际无人」的不一致状态?
进出车在原版里是一条多步的动作链——接近车门、开门、入座、放人、关门,每一步都可能失败,并需要可回退。那条动作链本身如何组织,是下篇「执行」侧的主题;本章先讲它的基础:座位关系——这条链无论推进到哪一步,「ped 和座位的关系」由谁建立、由谁清除,出现分歧时由哪一侧作为权威。
① 原版机制
原版对座位关系有明确的一对操作:把 ped 加进某个座位、把 ped 从座位移除。这一对是「建立关系」和「清除关系」的权威操作——进出车那条动作链上的所有叶子,最终调用的就是它们。
一个值得单独点出的设计:原版把「入座」(播放进入座位的动作过程)和「把 ped 放进座位」(执行最终的「ped 归位」交接)拆成了两个独立的叶子任务——源码里这是两个分离的执行叶,一个演动作、一个做最终归位交接。预约解决的不是座位问题,而是「未来可能发生的关系」如何被提前建模。 至于原版为什么把动作叶与关系交接叶分开,笔记没有直接给出动机;但从整套系统的设计哲学去推,一个合理的解释是:「动画播到哪」和「关系何时正式建立」本就是两件事——动作可以被打断、可以重播,而关系的建立需要是一个明确的、原子的时刻,两者若合并,动画被打断时就可能留下关系脏状态。无论动机如何,可以确定的事实是:「关系变更」在原版里被实现为一个独立的交接叶,不是动作叶的副产品。
这一章的意义:座位不是「有人/没人」的布尔,而是一条有权威方、有中间态、可对账的关系。关系的建立/清除有唯一权威操作、动作与关系分离、失败必须回退——这是整条进出车链(下篇)不留脏状态的根。原型把这条关系立成了三层:意图层(可拒绝、可回退的解析)、权威操作层(门链、原子、回退)、一致性层(双向对账、定向修复)——本章③会逐层对照真实代码。
② 关系模型与批处理边界
座位关系的核心难点,是「修改两个实体(ped 和车)之间的关系」:
- 修改 ped↔车的关系要同时、原子地动两个实体的状态,还要在失败时回滚——在纯数据导向的实体表示里,跨实体的原子关系变更通常需要额外的事务边界与回滚机制。
- 座位占用的一致性(座位说有人 vs ped 说在车里,两边必须对得上)是一个跨实体不变量,不能假定由批处理框架自动维护。
一个具体的失败场景可以说明「原子性」的必要性。设想两个 NPC 几乎同时冲向同一辆车的驾驶座:A 先把座位标记成「A 占用」,正要去设 A 自己的「我在这辆车里」时,一次调度让 B 也跑了进来。如果没有原子性和权威方,就可能出现「座位说 A 占用、A 却还没认这辆车、B 又以为座位空着也来占」的三方不一致——最后这辆车要么两个人挤进一个座位、要么座位显示有人却谁都不认。原版和原型均通过「座位关系只有唯一权威操作,每步失败即回退」来避免这类状态缺口:占座失败就立刻把座位释放、绝不留下残缺状态。ECS 的扁平数据行模型天然不提供这种「跨两个实体的原子事务 + 失败回滚」,你得在框架外自己搭一套,等于 ECS 在这层不仅提供不了帮助、反而增加了负担。
③ 原型实现与规划状态
这一层原型有大量真实代码,而且把「干净回退」和「权威归属」两条纪律落得非常扎实。
座位关系的权威操作在 FVWVehicleSeatManager,对应原版那一对:AddPedInSeat / RemovePedFromSeat / MovePedToSeat。读 AddPedInSeat 的真实实现,它就是角色篇反复讲的那条有序门控链 + 失败回退的又一次落地:
- ped 句柄无效 → 返回
InvalidPedHandle; - 车句柄无效 →
InvalidVehicleHandle; - 座位索引无效 →
InvalidSeatIndex; - 这个 ped 已经在别的车里 →
PedAlreadyInVehicle(不能一人占两座); - 目标座位已经有人 →
SeatOccupied; - 以上都过,才真正把 ped 指派进座位;
- 指派成功后,再设 ped 侧的占用快照——如果这一步失败,立刻把刚指派的座位清掉、返回
PedOccupancyMismatch。
第 7 步是整段的关键:它不是「设完就完」,而是「设失败就回退」——如果 ped 侧占用没设成功,它会 ClearSeatOccupant 把车侧刚建立的关系撤销,绝不留下「座位有人、ped 却不认」的残缺状态。这正是原版「进出车链每步可回退」在关系操作层的落地。最后它还顺带清掉这个 ped 可能残留的座位预约。每一个失败分支都是一个精确的枚举(EVWVehiclePedSeatRelationshipStatus 全部十一种:三种成功态 AddedPedInSeat / RemovedPedFromSeat / MovedPedToSeat,八种失败态从 InvalidPedHandle 到 SeatOccupantMismatch),不是一个含糊的 false。
进一步分析,还有三处值得单独说明的实现约束:
其一,返回的结果对象本身就是一份审计记录。 FVWVehiclePedSeatRelationshipResult 不只带状态枚举,还带:变更前后的 ped 占用序列号(PreviousPedOccupancySerial / NewPedOccupancySerial)、之前座位上是谁(PreviousSeatOccupant)、之前的驾驶指令归属是谁的第几号授权(PreviousControlCommandOwnerPed / OwnerSeatIndex / OwnershipSerial)、以及三面「这次调用真的改了什么」的标志(bMutatedVehicleSeat / bMutatedPedOccupancy / bClearedDriverControlCommand)。调用方拿到的不是「成功/失败」,而是「这次操作前世界什么样、之后什么样、具体动了哪几块」——出问题时不用重放,读结果就能回溯。
其二,座位层的任何一次变更,都会自动联动检查驾驶权。 读 ConfigureSeats / AssignSeatOccupant / ClearSeatOccupant 三个底层操作的实现,它们共享同一个收尾动作:先把变更前的驾驶指令归属存下来,变更后立刻用它重建一次一致性快照——如果这次座位变更让原本 driver-owned 的指令归属变成了过期(bStaleDriverOwner),就当场 ClearControlCommand 把指令连同归属一起清掉,并在结果里标 bClearedDriverControlCommand。「司机换了/座位清了,驾驶指令跟着失效」不是靠上层记得去清,而是座位层自动保证的——第四章讲的幽灵驾驶检测,在这里是每次座位变更的内建收尾,不是事后巡检。
其三,移除和换座同样是门链,不是直接清。 RemovePedFromSeat 要过四道检查:ped 有没有占用(PedNotInSeat)→ ped 快照里记的车和座对不对得上(PedOccupancyMismatch)→ 座位上的人是不是这个 ped(SeatOccupantMismatch)→ 全对才清座、清 ped 占用、连带清这个 ped 挂在该座上的预约。MovePedToSeat(换座)则允许目标座是自己(幂等重入),但被别人占着就返回 SeatOccupied;换成后,如果旧座和新座不同,还会把旧座上残留的预约清掉。建立、移除、换座三条路径,每条都是「验证链 + 原子变更 + 连带清理」的同一个形状。
配套的还有一组防线:AssignSeatOccupant 会统计同一个 ped 是否还占着别的座(CountDuplicateOccupantSeats,结果记进 ClearedDuplicateCount)——一人多座是数据损坏的典型征兆,发现就记录;而「这次赋值会不会真的改变座位状态」也有一个专门的判定(同一个人已坐在同一座、驾驶座标志也一致,就不算 mutation)——幂等调用不产生假变更,这让「有没有真的动状态」这个信号可以被测试断言。
而「权威归属」这条,原型用一个注释明确约束了:ped 侧的占用状态 FVWPedVehicleOccupancyState 明确标注是「ped-local, non-authoritative snapshot only; it does not prove the Vehicle is live or the Vehicle seat still matches」——ped 侧只是快照,权威在车侧的座位状态。 车侧也有一条对称的注释:「Vehicle-local seat queries only validate stored handles; they do not prove the occupant is still live in FVWWorldRuntime」——连车侧自己的座位查询,也只验证存着的句柄格式,不担保那个 ped 还活在世界运行时里。两条注释合起来,是这套系统对「本地状态能证明什么、不能证明什么」的精确措辞:每一层只为自己存的东西负责,跨层的真伪靠对账。
两边可能因为时序而暂时不一致,所以原型专门有一套一致性检查与修复。对账函数 BuildPedSeatRelationshipSnapshot 是一条九态门链:ped 句柄无效 → 车句柄无效 → 座位索引无效 → ped 没有占用记录(NoPedOccupancy)→ ped 记的车不是这辆(PedVehicleMismatch)→ ped 记的座不是这个(PedSeatMismatch)→ 座位是空的(SeatEmpty)→ 座位上是别人(SeatOccupiedByOther)→ 全对才是 Consistent。注意它是双向对账:前半段查「ped 声称的」对不对得上车,后半段查「车记录的」对不对得上 ped——单向检查会漏掉「ped 认车、车不认 ped」这半边。快照里还平铺了七八面布尔(bSeatOccupantMatchesPed / bPedOccupancyMatchesVehicle / bPedOccupancyMatchesSeat / bPedMarksDriver …),让调用方不用再解析枚举也能逐条看。
修复函数 RepairPedSeatRelationship 则是按方向显式授权的双通道:调用方必须指明修哪边——ClearStalePedOccupancy(清 ped 侧的过期占用)或 ClearStaleVehicleSeatOccupant(清车侧的过期占用者),各走一座专门的一致性桥(FVWPedVehicleOccupancyConsistencyBridge / FVWVehicleSeatOccupantConsistencyBridge)。修复不猜方向——「该信车还是该信 ped」是调用方的决策,修复层只负责安全执行。而且修复是复合的:清掉 ped 侧过期占用成功后,它会自动再跑一次驾驶指令归属修复(RepairDriverCommandOwnership)——因为 ped 侧占用一清,原本挂在它身上的驾驶权大概率也过期了。每次修复都带修复前后两份快照(BeforeSnapshot / AfterSnapshot),修没修好、修完还剩什么不一致,结果可直接审计。这套「快照非权威、权威在车侧、对账双向、修复定向且留痕」的设计,就是「避免悬空关系」的工程化保证。

原型还多做了一层原版笔记里没细展开、但联机必需的机制——座位预约:ReserveTaskSeat → ConfirmTaskSeatReservation → ReleaseTaskSeatReservation 三步,外加「替换同源的过期预约」。为什么要预约?因为一个 NPC 决定去坐某个座位、到真正坐进去之间有一段时间(它还在走向车门),这期间得先把座位「订下」,免得两个 NPC 同时冲一个座位。这是把「进车是有时延的过程、不是瞬间」这件事显式建模。
这套预约值得进一步分析,因为它把「具有时延的意图」如何安全建立占位、同时避免永久占位,落实为一套统一流程。真实实现中,三步操作使用同一个「预约流程动作」入口,仅传入不同的动作类型(预约 / 确认 / 释放),并且每一步都带一个预约序列号和请求方句柄。两者共同回答一个关键问题——当前座位上的预约是否仍对应最初的请求:
- 预约(Reserve):把座位标记成「被某 ped、某次请求(带序列号)订下」。此时座位还没真正有人,但别的 NPC 看到它已被预约,就不会来争。
- 确认(Confirm):NPC 真的走到、要坐进去了,用同一个序列号来确认——序列号对得上,才认;对不上(说明这个预约已经被后来的请求顶替、或已过期),就拒。
- 释放(Release):坐进去了、或放弃了,把预约清掉。还有一个「替换同源的过期预约」——同一个来源的旧预约过期了,新请求可以顶替它,而不是被「已有预约」挡住。
这套「预约—确认—释放」+ 序列号的设计,本质是一个带版本的、可超时的占位协议。 它解决的是分布式/异步系统里的经典问题:一个意向要先占资源、但占了不能不还、还要能识别「这个占位是不是已经作废」。原型把它用在座位上,联机时尤其关键——两台机器上的 NPC 可能同时想要一个座位,靠序列号和权威方(座位归车侧)才能裁决谁真的坐上。注意这又是「永不将状态压缩为布尔值」纪律的体现:座位不是「有人/没人」两态,而是「空 / 被预约(谁、哪次) / 被占用」的分级状态,预约态是「空」和「占用」之间那个必需的中间态——少了它,有时延的进车就一定会冲突。
这套预约协议底下的策略对象,还有两层值得拆开看的纪律。第一层是「调用方阻断」:策略的输入里带五面「这次调用会不会牵连别的系统」的旗——会触发实体生命周期?会需要扫描世界?会需要物理移动?会动任务生命周期?会调用解析器?任何一面为真,策略直接返回对应的精确拒绝状态,什么都不做(WouldRequireEntityLifecycle / WouldRequireWorldScan / WouldRequirePhysicalMovement / WouldRequireTaskLifecycle / WouldRequireResolver 五个枚举值)。这和角色篇对象转换策略那句「不扫描、不流式、不创建销毁」是同一条边界纪律的预约版:策略层被严格限定在「只动预约状态」的边界内,越界的需求必须由调用方在外面先解决。第二层是「预约前的对偶预检」:预订和确认动作在真正动预约之前,会先跑一遍座位对偶一致性检查(ped 侧占用和车侧座位两个方向各查一遍),查出问题时返回方向精确的状态——ped 侧的账损坏是 StalePedSidePair,车侧的账损坏是 StaleVehicleSidePair。预约绝不落在一笔坏账上:如果 ped 和座位的现有关系本身不一致,先修账(第二章末尾讲的修复通道),再谈预约。另外五种预约动作里有两种是纯检查(CheckReservationReadiness / CheckReservationConflict,只查不改)——「先问一句能不能订」和「真的下订」是两个动作,上层可以廉价地探测而不产生副作用。
实现层面还有三个值得记的细节。其一,预约状态住在座位上,不住在 ped 上。 FVWVehicleSeatState 里直接嵌着一份 FVWVehicleSeatReservationState(七个字段:是否已订、订给哪个 ped、哪辆车、哪个座、谁提的请求、请求标签、序列号)——预约和占用一样,权威在车侧;ped 侧不存「我订了哪个座」的权威记录,要查就来问座位。其二,判定和写入是分开的。 座位管理器的三步操作,内部全部委托给一个纯策略对象 FVWVehicleSeatReservationFlowPolicy::EvaluateAndApply——策略只算「这次动作该不该被接受、接受后预约态应该变成什么」,返回一个 NextReservation;真正把它写回座位的是管理器。这和角色篇讲的对象转换策略(「只裁决,不扫描不创建」)是同一个模式:策略可以脱离世界单测,写入点只有一个。其三,「同源顶替」有自己的判定链。 ReplaceStaleSameSourceTaskSeatReservation 依次检查:座位上有没有预约(没有 → MissingReservation)→ 现有预约是不是同源的(用 ped+车+座+请求方四元组判「同源」,不是就 → ConflictingReservation,不许顶别人的)→ 是不是连身份都完全一样(请求方+标签+序列号三元组全同 → AlreadyReserved,幂等返回)→ 只有「同源但序列号/标签已变」才走「清旧订新」。「顶替」被限定为「同一个来源用新请求替换自己的旧请求」——它永远不能变成抢占别人预约的后门。整套预约管理的返回状态一共十种(EVWVehicleSeatReservationManagerStatus,从 ReservedSeat 到 ReservationIdentityMismatch),名字含 Reservation 的自动化测试有 74 条。

最后看这一层的顶盖:座位意图解析器(FVWVehicleSeatIntentResolver::Resolve)。它是「ped 的一个进/出车意图」到「上面那些权威操作」之间的翻译层,读它的实现(约 480 行)能看到几条很成熟的产品语义:
- 意图有门槛:没有意图(
NoIntent)、意图不在Requested态(IntentNotRequested)、意图的目标车不是这辆(TargetVehicleMismatch)——这三种情况解析器只报告、不消费,意图原样留着;而真正的失败(座位无效、被占)才会把意图置为Rejected。「不归我管」和「我处理了但失败」是两类完全不同的返回。 - 乘客座回退是一套显式规则,而且尊重预约。进车意图带一面
bAllowPassengerFallback旗:目标座被占且允许回退时,解析器先看「这个 ped 是不是已经坐在同一辆车的别的座上」(是就优先沿用当前座),否则从头扫第一个非驾驶座、无人占用、且没有被预约的座。注意最后一条——回退选座绕开一切已预约的座位,第二章讲的预约协议在这里被完整尊重;而且回退一旦生效,bDriverSeatIntent强制清为假——回退永远不会把一个乘客兜进驾驶座。找不到回退座,才返回SeatOccupied。 - 解析顺便修复。如果 ped 已经坐在目标座上、但 ped 侧占用快照丢了(两边不一致的典型),Enter 解析会重新执行赋座并补写 ped 侧占用——对应测试名就叫「EnterRepairsStalePedOccupancy」;Exit 解析同样有「车侧认、ped 侧记录已损坏」的宽容路径:确认座位上确实是这个 ped 之后,清座、清 ped 占用、照常完成意图——测试名「ExitClearsStalePedOccupancy」。解析器把「解析过程中发现的不一致」当场修掉,而不是抛错让上层重试。
- 换座是自动识别的:同一辆车里已有座位的 ped 发 Enter 意图到另一个座,解析器自动走
MovePedToSeat而不是AddPedInSeat——上层不需要区分「上车」和「换座」,它只表达「我要坐那个座」。 - 意图完成时回填结果:如果意图没指定座位(交给解析器选),完成时解析器会把最终坐进的座位号写回意图状态——「你没说要哪个座,我告诉你最后坐了哪个」。
这层解析器 + 底下的权威操作 + 预约协议 + 对账修复,合起来就是第二章的全貌:意图层(可拒绝、可回退)→ 权威操作层(门链、原子、回退)→ 一致性层(对账、定向修复),三层各管一段,状态在每一层都留痕。
诚实边界:座位关系、预约、意图解析、一致性修复这些状态与操作是真实、可单测的代码;但这条关系之下的执行链——开门/入座动画/放人/关门的真实运行时——尚未接入(Layer5 文档明写是 deferred),那部分的设计与迁移规划放在下篇讲。也就是说,「关系建立、失败回退、状态对账」是实的,「开门入座的动画和物理」尚未实现。
三、网络克隆与迁移:跨机器车辆状态的一致性
本章讨论的问题:联机状态下,车辆可能在一台机器上保存权威实例,在其他机器上保存克隆实例。权威跨机器迁移时,驾驶任务、座位关系和驾驶权如何保持一致,避免状态冲突或重复实例?
前两章讲的都是单机内的所有权。这一章补上联机这一维——它不是「在单机之上包一层网络代码」,而是从一开始就内建在车辆运行时的所有权模型里。这也是为什么它值得单独成章:网络感知一旦是「事后补的」,就永远补不干净。
① 原版机制
原版处理这件事的方式,用一句话概括:网络克隆是车辆运行时的一等公民,不是第二套架构。
先看车侧。驾驶智能从一开始就区分本地车和网络克隆。对克隆车,它的 Process() 跳过普通的本地控制工作,转而更多依赖:附近实体扫描、路线/junction 刷新、以及从网络对象读来的克隆 AI 任务数据。也就是说,克隆车不是「本地车少跑几步」,而是走一条不同的更新路径——它的驾驶意图不是本地算出来的,是从网络对象同步过来的。那些「取当前活动任务信息 / 取节点列表 / 取跟随路线 / 取路线搜索辅助」的辅助函数,都显式地在「本地任务树状态」和「存在网络对象里的克隆状态」之间分支。网络感知是内建进车侧主所有者的,不是在外面包一层。
再看 ped 侧的桥。第一章那座「ped 向车辆提交驾驶任务」的桥,同时也是网络接管层。它的克隆相关行为包括:创建可查询状态、读可查询状态、为克隆 ped 创建任务、为本地 ped 创建任务、更新克隆的状态机。它序列化的内容是:目标车、驾驶任务类型、以及序列化后的驾驶任务数据——然后在克隆/本地迁移时,通过驾驶智能的「为网络创建驾驶任务」重新构建任务状态。
这里就能看出「统一命令载荷」为什么重要了(下一章会展开它):因为「一个驾驶意图」被表达成了一个标准包(目标、位置、驾驶标志、巡航速度……),它才能被序列化、能在克隆/本地之间迁移、能在网络接管时重新水化。如果每个驾驶任务各带一套互不相干的数据形状,联机迁移就无从谈起。
进出车这条链同样如此:网络与克隆逻辑主要改变的是权威、状态序列化、座位/组件预约检查、以及同样的状态机如何在克隆上恢复或重放——但进出车的外层 FSM 和窄叶子不变。多人是穿过同一套家族的横切覆盖,不是第二套通用车辆访问栈。
这一章的意义:联机不是给单机逻辑外面套一层同步。它要求「权威归属」「状态序列化方式」「迁移时的重建方式」从一开始就是所有权模型的一部分。把驾驶意图表达成可序列化的统一载荷、把克隆分支内建进车侧所有者和 ped 侧桥——网络感知才可能是干净的,避免形成分散的补丁逻辑。
② 关系模型与批处理边界
这一章是前文关系边界在联机场景中的延伸。网络克隆/迁移的核心是权威归属:车辆的权威实例位于哪台机器、驾驶任务属于哪个 ped、迁移时状态如何从一台机器转移到另一台并重建。这是一整套「跨机器的对象身份与所有权」逻辑,需要独立的关系与版本机制,不能只依靠批处理实体表示。
而且原版把网络感知内建进任务 FSM 和车侧所有者——克隆有克隆的更新路径、迁移要重新水化任务。「这个实体在别的机器上有个克隆、它的权威在那边、状态要在两边同步」这种跨机器所有权,仍需在实体表示之外明确建模。因此这一章的重点不是再次论证某个框架的优劣,而是说明跨机器身份、权威和版本如何成为车辆运行时的一部分。
③ 原型实现与规划状态
诚实说清楚:网络克隆/迁移在原型里目前未实现——但所有权模型已经为它留好了两块关键基础。
第一块是句柄与序列号。原型从最底层就用 FVWEntityHandle(稳定句柄)而不是裸指针在系统间引用实体,而且座位关系、占用、驾驶权归属都带序列号(OccupancySerial / ReservationSerial / OwnershipSerial)。这两样正是网络同步的前提——句柄让「这个引用还有效吗」有统一判断,序列号让「这是不是我当初那次操作」可校验(迁移后能判断状态是否过期)。角色篇讲基础层时说过「句柄是为将来脚本/网络/调试统一身份模型」,这里就是那个「将来」的一部分。值得预告一句:序列号的单调性在实现里守得很严——第四章会看到,归属序列号只在真实变更时递增(幂等设置不递增),而清空归属本身也算一次变更、同样递增,序列号在实体生命周期内永不回卷。这条性质对联机至关重要:迁移后拿旧序列号来对账的一方,永远能被判定为过期,不存在「序列号被复用导致旧引用误判为有效」的窗口。
第二块是权威与非权威的显式区分。上一章说明 ped 侧占用是「非权威快照」、权威在车侧;驾驶权一致性章节也把「归属」和「实际占用」的对账做成了独立模块。这套「权威方与非权威方对账」的区分,正是网络接管所需要的——迁移本质上是「权威从一台机器转移到另一台,非权威方重新对账」。上一章末尾的两条「解析过程中同步修复」路径(车侧记录有效、ped 侧记录失效时继续完成并补正)也应从联机视角理解:跨机迁移后常见的状态是两侧记录存在时序差异——单机阶段建立的「以权威方为准、修复非权威方」能力,可以直接服务于联机迁移。
把这两块放在一起,能看出原型对联机的准备策略:它没有提前写任何一行网络代码(那会是没有验证对象的空中楼阁),但它把每一个将来要被网络同步的状态,都提前做成了「句柄引用 + 序列号版本 + 权威方明确 + 可对账可修复」的形状。联机友好不是一个模块,是一种状态建模纪律——这是网络克隆这章虽然「原型未写」、却不算「毫无进展」的原因。
诚实边界:上面两块是已落地的基础层能力(句柄、序列号、权威区分都是真实代码);但真正的网络层——克隆的更新路径、驾驶任务的序列化、迁移时的任务重新水化、跨机器权威转移——原型里一行都还没写,是明确的规划项。也就是说,「所有权模型对联机友好」是真的,「联机本身」尚未实现。 这一章的③诚实地更偏「地图」而非「已铺的路」。

四、驾驶权一致性:驾驶授权与实际占用的一致性
本章讨论的问题:车辆控制权可能由多个来源(玩家、AI、脚本)竞争,每个更新周期只能接受一个来源。选定来源后,如何保证其授权状态与驾驶座实际占用持续一致,避免出现过期驾驶权?
这一章是本篇最能体现「所有权纪律」的一章,也是原型里做得最完整、最能对照真实代码的一块。它其实是第一章「车拥有驾驶循环」和第二章「座位关系有权威方」两条线的交汇点。
① 原版机制
原版这一层的语义,散落在「驾驶任务契约」和「网络克隆权威」里,核心是一个共享的驾驶命令载荷(下称 MissionParams):目标实体、目标位置、阻挡尺寸修正、到达距离、驾驶标志、巡航速度、最大巡航速度。这个载荷是可复用的命令包,穿过驾驶智能的任务工厂、具体驾驶任务、以及网络任务的序列化与迁移。
也就是说,原版的驾驶任务是围绕一个统一命令载荷参数化的,而不是每个任务各带一套互不相干的数据形状。这带来一个直接好处:驾驶任务能被序列化、能在克隆/本地之间迁移、能在网络接管时重新水化——因为「一个驾驶意图」被表达成了一个标准包,而不是散在各处的状态。
而「谁在开」的权威,在原版里是通过网络对象的权威 + 驾驶任务的归属来保证的:本地车由本地任务树驱动,克隆车的驾驶任务数据从网络对象读、由 ControlVehicle 桥重新水化。这条「驾驶任务知道自己属于哪个 ped、能在迁移时重建」的线,是原版驾驶权一致性的隐形骨架。
(诚实说明:原版这一层我读到的是「统一命令载荷 + 网络克隆的任务水化」这个结构,具体的「驾驶权归属校验与修复」在原版里更多是隐含在网络权威和任务归属里,没有一个像原型那样显式的、独立的一致性校验模块。原型在这里做了一个比原版更外显的设计——见③。)
这一章的意义:多来源竞争一辆车的方向盘,选中之后还得持续保证「在开车的来源」和「坐在驾驶座的 ped」一致。把驾驶意图表达成统一载荷,是它能被仲裁、能被序列化、能在网络迁移时重建的前提;而驾驶权的归属必须可校验、可修复,否则就会出现幽灵驾驶。原型在这一章交出的是全篇最完整的一条实现链:仲裁、双门接受、封闭写入、序列号版本、十态校验、定向清除、显式修复——③段逐段对照。
② 关系模型与批处理边界
驾驶权一致性是一个跨实体不变量:「车的当前驾驶指令归属」必须和「车的驾驶座占用」持续对齐。纯数据导向的实体表示不会自动提供这类「两个实体之间必须守恒的关系」,跨实体的不变量仍需由运行时的权威操作、版本字段和校验路径维护。
具体说,这个不变量牵扯的是两个不同实体上的两块状态:车上存着「当前驾驶指令的归属」(谁授权的、哪个座位、哪棵任务树),驾驶座上存着「当前占用者」(哪个 ped)。这两块状态在不同实体上,却必须始终指向同一个 ped。在 ECS 里,这意味着你要么把两块状态并进同一个实体(那车和 ped 就不能是独立实体了,违背 ECS 的建模)、要么写一个专门的 Processor 每帧扫过去比对两块跨实体状态并修复——而后者恰恰是「逐个对象、带大量分支」的逻辑,ECS 的批处理优势在这里一点用不上。
而且驾驶指令仲裁(多来源按优先级选一个)+ 归属校验(选中的来源还合法吗)+ 归属修复(检测到 stale 就清)是一条逐个对象、带分支、有状态的裁决链,不是无状态批处理。关键在于归属带序列号——每次授权都递增一个序号,好在后续判断「当前挂着的这个归属,还是我当初那次授权的吗,还是已经被后来的授权顶替了」。这种「带版本的、需要跨帧比对的状态」必须由有状态的关系层显式维护。换句话说,批处理实体表示可以高效地告诉你「这一万辆车此刻各自的速度」,但不能替你回答「当前车辆控制权应归属于哪个来源、那个『谁』还合法吗」——后者是关系和版本。
③ 原型实现与规划状态
这是原型里做得最完整的一块——它把「驾驶权一致性」做成了一个显式的、可单测的模块,比原版更外显。 整条链有三段:仲裁 → 接受 → 一致性校验与修复。
第一段,指令仲裁。 FVWVehicle::EvaluateCommandCandidateArbitration 接收一组「指令候选」(每个带来源类型、标签、优先级、是否就绪),遍历所有就绪候选,选优先级最高的那个,返回「选中了哪个 + 为什么(SelectedHighestPriorityReadyCandidate / NoReadyCandidates)+ 候选总数 / 就绪数」。这对应玩家、AI、脚本等多来源控制请求的优先级仲裁——而且没有就绪候选时返回的是一个明确的理由,不是静默什么都不做。
值得注意的是「候选」这个措辞,以及每个候选带的那面「是否就绪」标志(bIsReadyCandidate)。为什么一个想控制车的来源,还要先过「就绪」这一关?因为一个来源想开车、和它此刻真的能给出有效指令,是两回事:玩家可能正在过场里不能操作、AI 驾驶任务可能还没算出这一帧的输入、脚本命令可能还没配全。「就绪」标志把「我想控制」和「我现在真能给出指令」分开——仲裁只在真正就绪的候选里选,一个高优先级但没就绪的来源不会因为优先级高就锁死方向盘、却又给不出指令、让车停滞。这个细节看似小,却是「多来源竞争控制权」不出现「高优先级来源占着控制权却给不出指令」这种死锁的关键。而返回值里还带「候选总数 / 就绪数」——这不是给运行时用的,是给诊断用的:一辆车行为异常时,你能看出「是根本没来源想控制它(总数 0),还是有来源想控制但都没就绪(总数非 0、就绪数 0)」,两种情况的排查方向完全不同。又一次「不将状态压缩为布尔值、连仲裁的中间信息都留痕」的纪律。

那「就绪」具体由什么构成?读 BuildCommandCandidateSnapshot 的实现:bIsReadyCandidate = 来源类型已知(非 Unknown)&& 来源标签非空——就这两条。特别注意它不要求「有运动输入」:油门/刹车/转向/手刹/倒车意图是否非零被单独记成 bHasMotionInput,但不参与就绪判定。这个取舍很讲究:一条「全零输入、来源明确」的指令是完全合法的候选——它的语义是「我(这个来源)命令这辆车保持静止」,和「没人在控制这辆车」是两个必须区分的状态。如果把「有输入」并进就绪判定,「AI 决定停车等红灯」就会被误判成「AI 没在控制」,方向盘被别的来源抢走——车会在红灯前被另一个来源开走。「零输入的指令」和「没有指令」的区分,是仲裁语义正确的暗桩。 还有平局规则:仲裁循环里只有「严格大于当前已选优先级」才换人——同优先级时先进列表的候选获胜,这个确定性顺序让「两个同优先级来源竞争」的结果可复现、可测试,而不是取决于哈希或内存布局的偶然。
第二段,接受驾驶指令并记归属。 AcceptDriverCommandCandidate 系列不只是「把指令设上去」,它还记录这条指令是被哪个 ped、哪个座位、哪棵任务树、什么优先级授权的(FVWVehicleControlCommandOwnershipState:owner ped、owner 座位、来源任务树、来源任务优先级、归属序列号、是否驾驶座拥有)。也就是说,车的当前驾驶指令带着它的「授权来源」一起存——这是后面能校验一致性的前提。
「接受」本身是一道双门。读 BuildDriverCommandCandidateSnapshot:它同时构建两份子快照——指令候选快照(上面讲的就绪判定)和驾驶授权快照(FVWVehicleDriverAuthoritySnapshot),两份都 ready 才算候选就绪。驾驶授权快照自己又是一条八态门链:ped 句柄无效 → 车句柄无效 → 车没有驾驶座(MissingDriverSeat)→ 驾驶座是空的(DriverSeatEmpty)→ 驾驶座上不是这个 ped(PedNotDriverSeatOccupant)→ ped 和驾驶座的关系快照不一致(PedSeatRelationshipMismatch,这里嵌套调用了第二章那条九态对账链)→ ped 自己的占用快照没标记自己是司机(PedOccupancyNotDriver)→ 全过才 Ready。注意最后一步:「车说你坐在驾驶座」还不够,「你自己的占用记录也得承认你是司机」——车侧和 ped 侧两边都对齐,授权才成立。这条链把「谁有资格给这辆车下驾驶指令」从一句直觉(「可简单理解为驾驶员」)变成了七项可逐条断言的前置条件。
写入侧的纪律同样严:SetControlCommandOwner 是 FVWVehicle 的私有方法,只有 FVWVehicleSeatManager 是友元——归属的写入权在整个代码库里只有座位管理器一个入口,任何别的代码都不可能绕过授权门链直接宣称「这车归我开」。它内部还有幂等检查:传入的归属和现有完全一致就直接返回、不递增序列号——序列号只在真实变更时前进,这让「归属换过几次」成为可信计数。反过来,SetControlCommand / ClearControlCommand 这两个「直接设/清指令」的公开方法,内部都会顺带清掉归属——直接塞指令 = 放弃授权语义,想要「带归属的指令」只能走座位管理器的 Accept 路径。而 ClearControlCommandOwner 清空归属时,序列号不归零、继续递增(清空也是一次变更)——归属序列号在整辆车的生命周期里单调递增,任何拿着旧序列号的引用都能被识别为过期。接受的结果对象照例是审计记录:变更前后的完整指令、两面 mutation 标志(bMutatedControlCommand / bMutatedControlCommandOwnership,逐字段比较得出)——连「这次接受实际上没改任何东西」都是可断言的事实。
第三段,一致性校验与修复。 这是最关键的一步。BuildDriverCommandOwnershipConsistencySnapshot 会检查一长串:车有没有当前指令归属?归属是不是驾驶座拥有的?owner 座位有效吗?owner 座位是驾驶座吗?owner 座位有人吗?owner 座位上的人,和记录的 owner ped 是不是同一个? 每一种不一致都是一个精确的枚举(EVWVehicleDriverCommandOwnershipConsistencyStatus 有十种:NoOwner / NonDriverOwned / Consistent / InvalidOwnerPed / InvalidOwnerSeatIndex / MissingOwnerSeat / OwnerSeatNotDriver / OwnerSeatEmpty / OwnerSeatOccupantMismatch …)。它专门有一个 bStaleDriverOwner 标志——这就是「幽灵驾驶」的精确捕获:AI 还挂着这辆车的驾驶指令归属,可驾驶座上的人早不是它了。 一旦检测到 stale,RepairDriverCommandOwnership 在显式授权下把过期的驾驶指令归属清掉。
这条校验链有两个容易读漏、但语义上很重的细节。其一,「没有归属」和「非司机归属」是合法态,不是错误。 读实现:NoOwner(这车现在没人认领指令)和 NonDriverOwned(归属存在但不是以司机身份拥有的)都直接返回 bConsistent = true——只有 driver-owned 的归属才继续往下跑那六步严格检查,而那六步里任何一步失败都把 bStaleDriverOwner 置真。也就是说,幽灵驾驶的定义被限定得很精确:「以司机身份挂着的归属,和驾驶座的实际占用对不上」——一辆合法停着的空车不会被误报,一条非司机来源的指令也不会被误清。其二,「谁装的指令,谁才能清」。 除了检测 stale 后的修复清除,还有一条正常退出路径 ClearDriverCommandForTaskSource:一个驾驶任务结束时想撤掉自己装的指令,必须五重匹配——owner ped 一致、归属确实有任务来源、来源任务树一致、来源任务优先级一致、会话标签允许(存储标签为空视为通配,否则必须相等)——全对上才清。一个任务不可能误清另一个任务装的指令,哪怕它们属于同一个 ped。修复函数照例带显式授权(bAllowCleanup 为假时只报告不动手,返回 Rejected)和修复前后双快照(bDetectedStaleDriverControlCommand 单独留痕)——检测、授权、执行、复查四步齐全。这一块的自动化测试:名字含 DriverCommand 的 32 条、含 DriverAuthority 的 4 条。
五重匹配防的是什么事故,值得用一个具体场景完整说明。设想同一个司机 ped 上,先后跑过两个驾驶任务:护送任务 A 先装了驾驶指令,随后被更高优先级的追击任务 B 顶替——B 通过 Accept 路径装上了自己的指令,归属里的任务出处已经换成了 B。这时 A 走到自己的清理代码(任务被顶替后的退出路径),调用「清除我装的指令」——如果清除只按 ped 匹配,A 会把 B 刚装上的指令清掉,车在追击中突然失控。有了任务树、任务优先级、会话标签这三层出处匹配,A 的清除请求会因为「归属的出处已经不是我」而被拒绝,B 的指令安然无恙——对应的测试名就叫 AbortReplacedOwner 系列。「归属记录出处」不是为了好看,是为了让「迟到的清理」永远清不到别人头上——异步系统里,「迟到的操作」是常态而不是异常,每一个写入口都得假设自己可能已经过期。

把三段连起来看:多来源竞争 → 按优先级仲裁(就绪者优先、平局先到先得)→ 选中者过驾驶授权八态门 + 指令就绪门,双门全开才被接受 → 授权来源连同任务出处、归属序列号一起记进指令归属 → 每帧可校验「归属」和「实际驾驶座占用」是否一致 → 不一致就精确定位是十种里的哪种、在显式授权下修复 → 任务正常退出时按五重匹配定向清除自己的指令。 这套机制比原版的隐式网络权威更外显、更可测——它把「驾驶授权与这辆车、还一致吗」从「隐含在网络对象里」提升成了「一个独立的、逐条留痕的一致性模块」。这也和角色篇讲的对象转换协议、本篇第二章讲的座位关系,是同一套「逐条状态枚举、每个拒绝/不一致都留痕」的纪律——整个原型从头到尾守着它。
诚实边界:仲裁、接受、归属、一致性校验与修复这些逻辑和状态是真实、可单测的代码。但它上游的「指令候选从哪来」——玩家控制器绑定、AI 驾驶任务的真实输出、脚本命令导入——尚未接入(SourceType 里 Player/AI/Script 的实际产生端是规划项)。也就是说,「多来源如何仲裁、归属如何校验和修复」是实的,「这些来源如何产生实际指令」尚未实现。
五、交通就绪门:单车参与资格的汇合点
前面四章立起来的状态——有没有司机(座位关系)、有没有驾驶指令来源(驾驶权)、在哪个 LOD 档(下一章)——最终会汇成一个问题:该车辆当前是否满足交通参与条件?
原型把这个判定做成了一条分级的就绪门链:BuildTrafficReadinessSnapshot 按严格顺序逐门检查——交通没启用、车句柄无效、被 dummy LOD 挡、没司机、没指令来源、缺车道、缺路线——第一个不满足的门就是答案,每一种「不够资格」都是一个精确的枚举,不是一个含糊的 bool。注意这条门链检查的每一项,恰恰都来自本篇立的状态:「没司机」查的是第二章的座位关系,「没指令来源」查的是第四章的驾驶权(具体判据是当前指令的来源标签非空——门链信的是「有一条来源明确的指令挂着」,不是「有人坐在驾驶座上」,两者由第四章的一致性模块保证对齐),「被 dummy 挡」查的是第六章的 LOD 档。就绪门是本篇所有所有权状态的下游消费者。 这条门链及其诊断视图在原型里被 117 条名字含 Traffic 的自动化测试覆盖,而且那些诊断视图本身是层叠构建的——壳门快照从首要阻塞者快照派生、元数据门聚合壳门、本地门链再聚合前两者——每一层都建立在下一层之上,而不是各自为政地重复计算。
顺着「单车视角」再补一个本篇立的所有权状态最终汇成的产物:单车自检门面。BuildInspectionFacadeSnapshot 一次性构建 17 份子快照——当前指令候选事实、运动学预览事实、就绪门链全家(就绪/边界失败/摘要/签名/问题标志位/首要阻塞者/壳门/元数据门/本地门链/状态行/状态签名)、物理集成诊断、待处理意图、预渲染诊断——再由 BuildInspectionDiagnosticLine 压成一行可读文本。一辆车可以一次性报告当前全部可观测状态,这是调试一座几百辆车的城时,「挑出任何一辆完整检查」的基础设施。配套还有一份交通参与者描述符(BuildTrafficParticipantDescriptorSnapshot):把司机、LOD 档、指令来源、车道/路线/场景/人口来源标签连同整条门链状态拼成一个描述键(DescriptorKey,附长度与字符和做快速比对)——将来交通系统按批巡检几百辆车时,不用逐字段比较,先比描述键有没有变。
但这条门链的主讲放在交通篇(上)——因为「够不够资格上路」本质上是交通系统的准入决策:门链是交通系统伸向单车的验收清单,门后的接管(车道跟随、路口、红绿灯)全是交通系统的事。那里会对照原型真实代码把七级门和整套诊断视图(壳门/元数据门/状态行)逐一拆开讲。本章只需要记住单车视角的这半句:一辆车把本篇讲的所有权状态全部立齐,才拿到进入交通系统的参与资格。

六、行为级 LOD:车辆行为降级的决策归属
这一章要解决的问题:远处三百米外的车,不必执行完整物理模拟和驾驶 AI——但谁来决定哪辆车此刻该降到哪一档?
角色篇讲过行为级 LOD 的通用道理(三档、滞后、预算、时间片)和「假乘客」。这一章不重复那些,只讲车辆侧「降档决策」这半边;降档之后车靠什么「维持运动表现」——运动学预览——是执行侧的事,放在下篇专讲。
① 原版机制
原版的车辆有三档 AI/物理 LOD:real(完整物理 + 完整 AI + 真实驾驶循环)、dummy(不跑完整刚体物理,沿路网做简化运动)、superdummy(连简化运动都进一步压缩)。由一个全局的车辆 LOD 管理器每帧按有效距离重排、分批、用滞后和 N-LOD 预算决定每辆车的目标档——这套调度语义和角色篇讲的行为级 LOD 是同一族。
而车侧智能在任务处理之后,会跑一次 dummy/superdummy 转换策略——决定这辆车下一帧该在哪一档。降档不是「暂停这辆车」,而是「换一套更低成本的方式让它继续动」——这个区别是「远距离实体仍保持连续更新」和「远处世界整体停止更新」的分水岭。至于那套「更低成本的方式」具体运动方式(运动学推进、不碰刚体),留给下篇。
这一章的意义:LOD 的决策侧是一个全局调度问题——按距离与预算,持续裁决每辆车该在哪一档。它和执行侧(降级后的低成本运动方式)是两件事:决策产生档位变更指令,执行负责「降级后维持运动表现」。本篇立决策,下篇讲执行。
② 车辆 LOD 的决策边界
这一层是角色篇「代理切换 vs Mass LOD」论点的车辆版,这里只留结论:Mass LOD 给的是「多久算一次」(降频/分桶),而车辆 dummy 要的是「算成什么」——一套和 real 档行为不同的运动学近似,以及档间无缝切换。降频框架做不了变形,代理切换要的是变形。 完整论证见角色篇,变形本身(运动学预览)见下篇。
③ 原型实现与规划状态
- 三档状态已实现:
EVWVehicleAILod { Normal, Dummy, SuperDummy },SetAILod / GetAILod / IsDummyAILod存取。一个措辞细节:IsDummyAILod对 Dummy 和 SuperDummy 两档都返回真——「是不是代理档」是一个二分问题(要不要跑完整交通/物理),「代理到什么程度」才是三档问题;第五章的就绪门链里「被 dummy LOD 挡」用的正是这个二分判定,SuperDummy 的车同样进不了交通。 - 诚实边界:那个「每帧按距离重排、用滞后和预算决定每辆车目标档」的全局 LOD 管理器还没实现——
SetAILod今天是一个不设防的裸 setter,谁都能调,因为它预期的唯一调用者(LOD 管理器)还不存在;等管理器落地,这个写入口大概率会像驾驶权归属那样收紧(私有 + 友元)。也就是说,「这辆车在哪一档」这个状态是实的,「行为档位的决策组件」尚未实现。 降档后的运动学推进有雏形,见下篇。
七、迁移取舍:车辆篇(上)的实现清单
把本篇各章合起来,给一张对照表。判据沿用角色篇的诚实口径:① 已落地(有函数体+测试)、② 已立骨架(类型状态齐、行为空)、③ 未开始(路线图条目,或原版 deep / 原型未写)、复用(该复用引擎)。
| 层 | 关键机制 | 原型状态 | 落点 |
|---|---|---|---|
| 驾驶所有权 | 车持有控制/智能边界 | ① 已落地 | FVWVehicle::DoProcessControl / ProcessIntelligence |
| 驾驶所有权 | 驾驶指令 + 来源类型 | ① 已落地 | FVWVehicleControlCommand + EVWVehicleCommandSourceType |
| 驾驶所有权 | ped 下发意图(进出车 step) | ② 已立骨架 | Layer4 TaskEnterStep / TaskExitStep |
| 驾驶所有权 | 车侧智能的具体服务(扫描/路网/junction) | ③ 未开始 | ProcessIntelligence 目前只记观测 |
| 座位关系 | 座位关系建立/清除/移动 | ① 已落地 | AddPedInSeat / RemovePedFromSeat / MovePedToSeat |
| 座位关系 | 失败原子回退 | ① 已落地 | AddPedInSeat 第 7 步回退 |
| 座位关系 | 权威在车侧、ped 侧非权威快照 | ① 已落地 | FVWPedVehicleOccupancyState 注释 + 一致性修复 |
| 座位关系 | 座位预约(reserve→confirm→release) | ① 已落地 | ReserveTaskSeat 系列(策略算/管理器写) |
| 座位关系 | 同源顶替(不许抢占他人预约) | ① 已落地 | ReplaceStaleSameSourceTaskSeatReservation |
| 座位关系 | 座位变更自动清过期驾驶指令 | ① 已落地 | Assign/Clear/ConfigureSeats 内建收尾检测 |
| 座位关系 | 双向对账快照(九态)+ 定向双通道修复 | ① 已落地 | BuildPedSeatRelationshipSnapshot + 两座一致性桥 |
| 网络克隆 | 句柄 + 序列号(同步前提) | ① 已落地 | FVWEntityHandle + *Serial |
| 网络克隆 | 权威/非权威区分(迁移前提) | ① 已落地 | 座位/驾驶权的权威区分 |
| 网络克隆 | 克隆更新路径/任务序列化/迁移水化 | ③ 未开始 | 原版 deep;原型未写 |
| 驾驶权一致性 | 指令仲裁(多来源按优先级) | ① 已落地 | EvaluateCommandCandidateArbitration |
| 驾驶权一致性 | 接受指令并记授权来源 | ① 已落地 | AcceptDriverCommandCandidate + 归属状态 |
| 驾驶权一致性 | 归属一致性校验(捕获幽灵驾驶) | ① 已落地 | BuildDriverCommandOwnershipConsistencySnapshot(十种精确状态) |
| 驾驶权一致性 | 归属修复(清过期驾驶权) | ① 已落地 | RepairDriverCommandOwnership(显式授权+前后快照) |
| 驾驶权一致性 | 归属写入权封闭 | ① 已落地 | SetControlCommandOwner 私有,仅座位管理器可调 |
| 驾驶权一致性 | 任务源定向清除(五重匹配) | ① 已落地 | ClearDriverCommandForTaskSource |
| 测试覆盖 | 全插件自动化测试 | ① 已落地 | 769 条(Seat 173 / Reservation 74 / DriverCommand 32 / Traffic 117) |
| 驾驶权一致性 | 指令来源的真实产生端(玩家/AI/脚本) | ③ 未开始 | 控制器绑定/AI 输出/脚本导入未接 |
| 行为 LOD | 三档状态 | ① 已落地 | EVWVehicleAILod |
| 行为 LOD | 全局 LOD 管理器(决定谁升谁降) | ③ 未开始 | 三档目前只观测 |
| 交通就绪门 | 分级就绪门链(够不够资格上路) | ① 已落地 | BuildTrafficReadinessSnapshot(主讲在交通篇·上) |
读这张表的一句话:本篇里,「所有权、座位关系、驾驶权一致性、LOD 状态」这几条骨架已经是真实、可单测的落地代码;而「具体行为」——车侧智能的扫描路网、指令来源的真实产生、全局 LOD 调度、网络克隆迁移——多数尚未实现。其中执行侧(驾驶执行栈、进出车动作、运动学预览、物理接缝)是下篇的主题,「很多车如何协同形成交通」是交通篇的主题。值得强调的一点:网络克隆一章原版证据充分、原型尚未实现——本篇诚实地把它讲成「迁移地图」,而非「已铺的路」。
建造顺序与关系边界的优先级
这张表画出的建造顺序,和角色篇一模一样,再次印证同一条心法:
先立所有权与一致性骨架(车辆持有控制循环、座位关系有权威、驾驶权可校验),再立成本与接缝(LOD 状态、就绪门),最后才填具体行为(真实驾驶执行器、交通 AI、物理驱动——下篇与交通篇的内容)。反过来先做具体驾驶行为,底层的所有权一旦动摇,就得全部重做。
这条顺序在测试的分布里也看得见:名字含 Seat 的测试 173 条、含 Traffic 的 117 条、含 Reservation 的 74 条、含 DriverCommand 的 32 条,而含 Kinematic 的只有 5 条、含 PhysicalIntegration 的 13 条——投入几乎全部压在「关系与一致性」上,「运动与物理」只立了接缝级的覆盖。测试分布就是工程重心的诚实记录:这个原型现阶段买的是「所有权永远不会错」,而不是「车已经会开」。
三条可迁移的实现经验
跳出车辆这个题材,本篇深挖出的实现纪律有三条是通用的,值得单独沉淀:
其一,结果对象就是审计记录。 本篇每一个会改状态的操作,返回的都不是「成功/失败」,而是「操作前什么样、之后什么样、具体动了哪几块、有没有连带效应」——变更前后的序列号、之前的占用者、之前的归属、逐面的 mutation 标志。这多花的几十个字段,换来的是出问题不用重放现场:读一条结果就能回溯全程。在「几百个实体互相纠缠」的系统里,这是排查成本的数量级差异。
其二,写入口越少越好,而且要用语言机制封死。 归属的写入权用「私有方法 + 指定友元」封闭在座位管理器;预约的写回点只有管理器一处(策略只算不写);直接设指令的公开方法会自动放弃归属语义。「谁能改这块状态」不是靠约定和代码评审守住的,是靠编译器守住的——语言机制能表达的所有权边界,绝不留给自觉。
其三,把「迟到的操作」当常态设计。 序列号跨清除单调递增、清除要五重出处匹配、预约确认要序列号对得上、意图消费要状态是 Requested——每一个写入口都假设「调用者手里的信息可能已经过期」,并给过期一个精确的、无害的出口。异步系统(以及将来的联机)里,时序错位不是 bug,是常态;在单线程原型里就把这套「过期识别」确立为默认设计,才是这套代码对联机最实在的准备。
而本篇反复强调的不是某个框架标签,而是关系和不变量——车与驾驶智能的所有权、ped 与座位的关系、驾驶指令归属与驾驶座占用的一致性。这些是「两个实体之间必须守恒的关系」,需要由明确的权威写入口、版本状态和校验协议共同维护。其余该复用引擎的部分(车辆刚体物理),在下篇物理接缝一章展开。
AI 协作复盘
这一篇有两条材料线:一套成熟开放世界引擎的车辆运行时逆向笔记,和一个正在开发、车辆侧已实现较多功能的 UE5 原型工程。
AI 帮了什么。 逆向侧,AI 把车辆运行时那条「车辆持有持久驾驶智能、ped 侧桥提交任务、克隆是一等公民」的所有权链,从多篇笔记里提炼对齐。原型侧,AI 逐行读了 VWVehicle.cpp(1922 行)、VWVehicleSeatManager.cpp(1226 行)和两个头文件的实际函数体,把「头文件里成片的状态枚举」对回原版机制并确认它们有真实实现——比如认出 AddPedInSeat 第七步的「设 ped 占用失败就回退座位指派」正是原版「进出车每步可回退」的落地、BuildDriverCommandOwnershipConsistencySnapshot 的 bStaleDriverOwner 正是「幽灵驾驶」的精确捕获、EvaluateCommandCandidateArbitration 就是「多来源按优先级竞争控制权」的仲裁;并对测试文件(约 7 万行、769 条自动化测试)做了分类普查,把「有测试覆盖」从一句口号变成了可引用的数字。深挖中还发现了几条只有读实现才能知道的机制——座位变更自动联动清过期驾驶指令、「零输入指令」不等于「没有指令」、归属写入权用 C++ 友元封闭在座位管理器——这些都写进了正文③段。
哪里分析偏差。 一个必须诚实记的自我修正:在做系列规划时,AI 一度判断「原型的车辆侧只覆盖了 LOD 一角」,据此差点把车辆当成「原版厚、原型薄」的规划型选题降低优先级。去读了原型车辆的真实代码后,这个判断被直接推翻——车辆侧其实是原型里已落地代码最多的区域之一(座位关系、驾驶权一致性、指令仲裁全是实的)。这也是车辆主题能撑起上下两篇、且多数章节第三段能「对照真实实现」而非「概念性规划」的原因。结论仍然是:在讨论原型实现状态之前先核对实际代码,不要依据对实现进度的印象作判断——印象和工程里真实的实现厚度可能相去甚远。
同一条教训还有一个「数字版」:本篇早期草稿沿用了角色篇的「基础层有 31 个自动化测试」——那是基础层刚落地时的数字,而这次动笔前重新普查,整个插件的测试已经是 769 条,涨了一个数量级还多。引用的数字和引用的结论一样会过时:工程在动,上一篇文章核对过的事实到下一篇动笔时就是「待复核」而不是「已知」。这次的做法是把普查本身做成一条可重复的命令(按测试名分类计数),每次写作前重跑一遍——数字从「记忆」变成「测量」。
测试名即规格说明。 这次深挖有一个方法论层面的发现值得记:这个原型的自动化测试是按「行为断言」命名的,测试名单本身就是一份规格文档——IntentDoesNotAutoScanTasks(意图桥不会自动扫描任务槽,必须显式请求)、IntentLifecycleIndependence(意图生命周期独立于任务生命周期)、EnterRepairsStalePedOccupancy(进车解析顺带修复过期占用)、AbortReplacedOwner(归属被顶替后旧任务正确中止)……写作时先扫一遍测试名单、再去读对应实现,比直接通读源码高效得多——测试名说明设计约束,实现说明约束如何被满足。这也反过来印证了系列心法:分级状态、精确枚举、逐条留痕的设计,天然是可测试的设计。
分级诚实是这篇的骨架。 本篇的几章横跨三种状态:部分章节已有完整实现(座位关系、驾驶权一致性——第三段对照实际函数体);部分章节原版证据充分、原型尚未实现(网络克隆迁移——第三段诚实地是「迁移地图」,只标出「哪些输出口/基础层已就位」,其余是规划);有的是纯盲区(驾驶算法内部、寻路内核)。全篇没有把这三种状态里的任何一种伪装成另一种——「立了骨架/接缝」绝不说成「车辆行为已经实现」,「原版这么设计」绝不说成「原型这么实现了」。这条分级诚实,是这类「逆向 + 在建原型」对照文章可信度的底线。
*这是「如何制作一款开放世界游戏」系列实战篇。车辆主题按层切成上下两篇:本篇(上)讲调度与决策——车辆驾驶智能的所有权、座位关系的权威归属、驾驶权的仲裁与对账、行为降级的决策归属;下篇讲行为、表现与物理——驾驶执行栈、进出车执行、运动学预览、物理接缝。原型当前把车辆的所有权、座位关系、驾驶权一致性、LOD 状态做成真实代码(本篇③段对照的车辆侧源码约七千行,全插件自动化测试 769 条);网络克隆迁移原版证据充分、原型未写,本篇诚实地把它讲成迁移地图。文中所有类名与源码路径均为长期逆向阅读与在建工程的实地核对结果,发布时按惯例做匿名化处理。*