关于 scenario-unity-architecture
面向 Unity 游戏玩法程序员和架构师的技能,着重确保架构在多次运行、对象销毁、存档、重构和合并后仍可靠。它为 Unity 6.3 提供经过测试的项目布局、脚本、任务和验证门槛。
主要功能
- 01可测试的 Core、Runtime、Editor 和 Tests 布局
- 02ScriptableObject 定义与纯 C# 状态
- 03带迁移检查的版本化 JSON 存档
- 04安全处理异步生命周期与静态状态
- 05输入重绑定和序列化数据审计
适用场景
- 01搭建 Unity 游戏玩法项目
- 02测试玩家重启后的存档持久性
- 03防止对象销毁后异步任务残留
- 04验证输入重绑定和资源重构
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ scenario-labs/ skills --skill scenario-unity-architecture安装后在 Claude Code 中输入/scenario-unity-architecture确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/critique.mdexpert-notes.mdgui-paths.mdprocedures.mdsources.mdscripts/AgentKit/Architecture/ArchInputAudit.csArchJobs.csArchPlayMode.csscripts/templates/Core/Game.Core.asmdefHealthModel.csHealthStats.csInventory.csSaveData.csscripts/templates/Editor/EventChannelEditor.csGame.Editor.asmdefItemDatabaseValidator.csscripts/templates/Runtime/Game.Runtime.asmdefscripts/templates/Runtime/Async/AwaitableExtensions.csLifetime.csRegeneration.csscripts/templates/Runtime/Composition/GameRoot.csscripts/templates/Runtime/Events/EventChannel.csFloatEventChannel.csStringEventChannel.csVoidEventChannel.csVoidEventListener.csscripts/templates/Runtime/Health/Health.csHealthConfig.csscripts/templates/Runtime/Input/BindingStore.csInputContext.cs详细介绍
Unity 架构(玩法程序员 / 架构师)
Unity 架构的专业水准,要看它能否经受住各种边界情况:第二次 Play 会话、await 期间对象被销毁、已构建的玩家程序重新启动、类重命名、两个分支合并,以及将预制件拖入空场景。数据存放在资源中,状态存放在纯 C# 和文件中,系统通过彼此不拥有的事件交互,而且每项断言都有一个 Agent 无需鼠标即可运行的测试来验证。目标环境:Unity 6000.3.21f1、Input System 1.20.0、Test Framework 1.6.0、macOS。如果此处提到的同级技能不在可用技能中,请让用户安装(npx skills add scenario-labs/skills --skill <name>);若无人值守,则根据工具架构继续操作,并标明这一缺漏。
必备背景: scenario-unity-expert(通道、审查循环、6.3 陷阱)。工具包:
ut_env、ut_run、ut_live,以及此技能的 scripts/ut_arch.py、
scripts/AgentKit/Architecture/ 任务和
scripts/templates/(经过测试的 Game.Core / Game.Runtime / Game.Editor / tests 布局)。
状态(2026-09-24): 所有流程均已在这台 Mac 上的 Unity 6000.3.21f1 中运行:
tests/code/unity-architecture/run_all.sh --live(结果见
archive/tests/unity-architecture/live_results.jsonl)。
立场(专业能力的差异)
- ScriptableObject 是定义,绝不是存档。 在 Play 模式下对 SO 的写入会保留在
Editor 中,并在每次启动构建版本时重置(Code Monkey,5a-ztc5gcFw [00:04:17])。观察到的情况
更为明确:写入只存在于 Editor 内存中(
IsDirty为 false,即使调用SaveAssets,.asset也不会改变),因此文件哈希检测不到;只有播放时进程中的内存防护 能检测到(so_guard,prefab SO 测试)。macOS 玩家版本连续运行两次,都是从烘焙值启动, 而存档 FILE 会保留。运行时修饰符作用于结构体副本(config.Stats, Tarodev,tE1qH8OxO2Y [00:11:18]);实时注册表是运行时集合,绝不搜索场景(Hipple, raQ3iHhE_Kk [00:40:06])。存档:带版本号的 id JSON、原子写入、备份、迁移。 - 在信任任何 Play 测试前,先了解 Enter Play Mode。 关闭域重载后(6.3 中的一个选项,
新建 6.6 项目的默认设置),静态计数器在第二次 Play 时读到 2,静态事件也有 2 个订阅者
(观察所得)。在
ExitingPlayMode时注销静态处理程序(Manual 的最佳实践;单独使用时 观察到运行正常),并在SubsystemRegistration时重置值;DoublePlay证明了这两点。 关闭场景重载会使 Editor 启动情况不具代表性,而脚本变更仍会重载域(6.3 Manual)。 - 每个 await 都必须有所有者。 await 会在 Destroy 和退出 Play 后继续执行(Unity,
eXkFPoEp3BA [00:13:28]):观察到没有令牌的循环在
Destroy后 0.3 秒内又执行了 13 到 14 次。MonoBehaviour 传入destroyCancellationToken(IO 也必须在退出时停止时使用Lifetime.Linked(this));纯 C# 服务传入Application.exitCancellationToken。绝不要 对同一个 Awaitable await 两次(池化对象:AsTask()),也绝不要阻塞等待 Task。发布版玩家 会跳过主线程检查:开发版玩家在非主线程调用persistentDataPath、Time.time、transform和new GameObject时会抛出异常,发布版玩家则不会(观察所得)。在线程相关 代码上进行冒烟测试时,使用开发构建版本。 - 设计师负责连线,代码负责决策。 序列化的 SO 引用是设计师可见的注入方式
(Hipple [00:14:39])。通道用于解耦发送方和接收方,反向遍历监听器
([00:33:23]),并配套提供调试方案:计数、日志开关、从作业调用
RaiseDebug()、Inspector 按钮([00:36:07])。UnityEvent 是序列化的函数调用([00:29:26]):仅用于少数由设计师连线 的响应(在 6.3 中测得每次调用为 0 B,但目标是按方法名称解析的硬引用)。由一个组合根 最先运行([DefaultExecutionOrder(-1000)],git-amend, PJcBJ60C970 [00:12:01];没有它时,观察到使用方找不到任何内容),并在失败时明确报错。 每个 prefab 都能在空场景中单独运行([00:16:29])。静态变量只适合小型个人项目 (Weimann,hQE8lQk9ikE [00:01:28];Tarodev [00:03:00]),并须重置。枚举仅用于按“某种 规则”固定的集合;可由设计师扩展的类别应使用 SO 资源([00:46:39])。 - 重绑定是一个有六个陷阱的状态机。 必须禁用操作(否则该包会抛出异常);默认情况下,
Escape 不会取消 Button 重绑定;复合绑定的头部不能重绑定;重复项根据
effectivePath判断,复合绑定部分只与之前的部分比较;修饰键组合由shortcutKeysConsumeInput控制 (默认关闭:Shift+B 也会触发 B);Reset All 会删除已持久化的覆盖项;除非传入false, 否则LoadBindingOverridesFromJson会清除现有覆盖项(samyam,csqVa2Vimao [00:12:51]、[00:27:44]、[00:33:22];1.20 手册;均已测试)。 UI 应从 Rebinding UI sample 开始:1.20 版已包含禁用和释放操作,但还缺少四项修复 (ut_arch.rebind_audit)。每次new一个生成的输入类都会创建独立资源:应共享同一个。 - 程序集是一份契约。 不引用引擎的 Core 中包含可在毫秒级测试的规则;运行时 asmdef 绝不
引用 Editor 程序集(Editor 可以编译,但玩家构建会失败,观察所得)。测试 asmdef 引用
Unity.InputSystem.TestFramework;在 6000.3.21f1 中不需要testables(观察所得)。 - 序列化数据会跨重构保留,合并则只在有意为之时进行。 字段使用
[FormerlySerializedAs],重命名的[SerializeReference]类使用[MovedFrom](不使用时, 条目会加载为 null,观察所得);每个[SerializeReference]类型都要添加[Serializable](6.3 中缺少它也能运行;根据版本说明,6.4 要求添加)。即使使用-l/-r,UnityYAMLMerge 在任何字段发生冲突时也会以状态码 2 退出;未使用这些参数时则会写入 BASE 值(观察所得): 检查退出码,然后在 Unity 中打开结果。
首先确定
团队构成(由设计师还是程序员编写行为逻辑)、项目生命周期、目标平台(Web
没有托管线程)、Enter Play Mode 设置、输入设备(键盘和鼠标、手柄、触控、
本地多人游戏)和修饰键快捷键、存档需求(存档槽、云端、迁移)、VCS(默认使用 Git + LFS)。默认设置:项目范围的操作、Game.Core/Runtime/Editor/Tests asmdef、SO 定义 +
纯 C# 状态、系统间使用事件通道、使用运行时集合管理注册表、一个 GameRoot、在 persistentDataPath 下保存 JSON
存档、使用带 UnityYAMLMerge 驱动程序的 Git。运行 ArchJobs.ProjectFacts,并将其答案写入计划。
工作流
- 搭建框架并编译。 目标:使用纯 C# 实现规则。方法:
ut_arch.install(P)、ut_arch.scaffold(P)(A1),然后运行ArchJobs.ProjectFacts(Unity -batchmode -nographics -quit -executeMethod AgentKit.Architecture.ArchJobs.ProjectFacts)。 GATE:编译错误和警告为零;ArchJobs.AssemblyGraph:Game.Core 的engine_refs == 0,violations == []。 - 将数据作为资源。 目标:定义可由设计师编辑。方法:使用
ut_arch.item_specs()运行ArchJobs.CreateAssets(编辑SerializedObject、为[SerializeReference]列表使用managed_refs,仅在发生更改时调用 SetDirty)(A2、A13)。GATE:第二次运行时全部为unchanged;ItemDatabaseValidator检查通过。 - 构建带测试的核心系统。 目标:物品栏、生命值、事件、集合。方法:使用模板;
ut_run.run_tests(P, "EditMode", assemblies="Game.Tests.EditMode")。GATE:全部通过;ut_arch.code_lint对运行时代码无警告或错误(A10)。 - 保存和加载。 目标:重启后状态仍然保留。方法:使用
SaveService(A3)。GATE:EditMode 往返测试、损坏文件回退、v1 迁移;在两个编辑器进程之间运行CrossProcessSave; 为持久性声明运行两次玩家程序(A4);当存档涉及线程时运行开发版玩家程序(A12)。 - 输入。 目标:支持所有设备,重绑定设置可持久保存并可重置。方法:项目范围的操作、
InputContext、RebindFlow、BindingStore,将.inputactions作为 JSON 编辑;ArchInputAudit.Audit(A5、A15)。GATE:使用InputTestFixture针对每个陷阱编写测试;审计结果中没有missing_scheme或duplicate_binding;rebind_audit没有缺失项。 - 异步和生命周期。 目标:不遗留后台工作。方法:使用带令牌的
async Awaitable Start()、AsTask、PlayMode 套件;异步测试使用返回内部 Awaitable 的[UnityTest],或使用async Task(A6、A7、A14)。GATE:销毁后不再有 tick;Prefab 隔离和 Prefab SO 防护检查通过。 - 强化 Play 模式。 目标:第 2 次会话与第 1 次会话一致,事件能触发响应。方法:
ArchJobs.StaticStateAudit,然后在项目模式下使用so_guard和invoke(对每个通道调用RaiseDebug)运行ArchPlayMode.DoublePlay(A8、A11)。GATE:自有程序集没有arch.static_without_reset;监视值在各会话间相等;so_changed_in_memory == [];每次 invoke 均为ok,且其响应已计数。 - 版本控制。 目标:合并和移动资源时引用不会损坏。方法:
ArchJobs.SetVcsSettings、ut_arch.write_vcs_files、git_setup_unity_merge、meta_audit、yaml_merge,然后运行ArchJobs.InspectAsset(A9)。GATE:meta_audit.ok;.meta不在 LFS 中;干净合并以 0 退出且能反序列化; 冲突保持未合并状态并附带报告。 - 交接并提供证据(采用 scenario-unity-expert 报告格式):每个步骤的 Unity 调用、Verified、Assumed。
数字
| 数值 | 相对于 | 来源 |
|---|---|---|
| 11 到 13 秒 | 此项目的一次批处理作业(包括启动编辑器) | 观测值 |
| 约 11 秒 / 16 秒 | EditMode 套件(33 个测试)/ PlayMode 套件(17 个测试),最终运行 | 观测值 |
| 13 到 14 个 tick | Destroy 后 0.3 秒内,不带令牌的 0.02 秒 Awaitable 循环 | 观测值 |
| 2 / 2 | 关闭域重新加载时,Play 会话 2 中的静态计数器 / 静态事件订阅者 | 观测值 |
| 4 次(共 6 次)/ 0 次(共 6 次) | 在开发版 / 发布版 macOS 播放器中,从主线程外调用 Unity 时引发异常的次数 | 观测值 |
| 0 B | 预热后 10,000 次通道触发或持久化 UnityEvent 调用的堆增长量 | 观测值 |
| 约 37,000 到 61,000 fps | 批处理 -nographics Play 模式(不限帧率):按时间等待,而非按帧等待 | 观测值 |
| 0.05 秒;false | PerformInteractiveRebinding OnMatchWaitForAnother;shortcutKeysConsumeInput 默认值 | 软件包源码 1.20;观测值 |
下一个 Update(30 fps 时为 33 ms) | 在主线程上启动的 Task 延续 | 6.3 手册 |
| 约 500 KB | 超过此二进制文件大小后,Git LFS 才有收益 | Pettersen 2016 |
| 退出码 0 / 2 | UnityYAMLMerge 无冲突 / 存在任何冲突字段(使用 -l、-r 时也一样) | 观测值 |
| 114.7 MB / 311.2 MB | 探测场景的 macOS Mono 发布版 / 开发版播放器 | 观测值 |
质量门槛
- 可度量: 编译错误和警告均为零;
AssemblyGraph.violations == [];完整套件中的 EditMode 和 PlayModefailed == 0,而非仅单个测试;运行时代码中的code_lint无警告或错误;so_guard和 prefab SO 测试均通过(SnapshotAssets的changed_on_disk == []只能证明编辑器工具没有保存任何内容);StaticStateAudit通过;DoublePlay会话数相同;ArchInputAudit警告数为 0;rebind_audit完整;meta_audit.ok;检查合并退出码;若声称支持持久化或线程功能,则提供播放器重启报告(A4)和 开发版播放器报告(A12)。 - 视觉(少量): 场景合并后,提供合并前后的相机截图(scenario-unity-expert
AgentCapture);重绑定界面显示新的绑定文本(scenario-unity-ui)。
常见错误
| 错误 | 表现 | 修复 |
|---|---|---|
| 将存档保存在 SO 中 | 在 Editor 中正常,构建版本重新启动后丢失 | 使用 SaveService 文件;SO 只读(A3、A4) |
| 通过文件哈希保护 SO | Play 写入 SO 时仍会通过 | 在运行中的进程里使用 so_guard / prefab SO 测试(A11) |
| 将会话状态保存在未引用的 SO 中 | 加载场景后,构建版本中的值恢复为烘焙值 | 保持引用,或将其保存在 C# 中 |
| 关闭域重载时使用静态变量 | 第二次 Play 从 2 开始,处理程序运行两次 | 在 ExitingPlayMode 时注销,并在 SubsystemRegistration 时重置 |
| 不带令牌的 await | 对象销毁或 Play 结束后,逻辑仍会运行 | 使用 destroyCancellationToken;服务使用 Lifetime.App |
| 对 Awaitable 等待两次 | 异常或卡住 | 仅调用一次 AsTask() |
| 仅在发布版本中检查线程 | 现在正常,之后行为未定义 | 在开发版播放器中进行冒烟测试(A12) |
| 正向遍历监听器 | 自行移除的响应会跳过下一个监听器 | 反向遍历 |
| 组合根没有执行顺序 | 消费方的 Awake 找不到服务 | [DefaultExecutionOrder(-1000)] |
| 重新绑定已启用的操作 | InvalidOperationException | RebindFlow 会在完成和取消时禁用并重新启用 |
Button 重新绑定时未使用 WithCancelingThrough | Escape 变成绑定键 | .WithCancelingThrough("<Keyboard>/escape") |
| Reset All 保留文件;加载时未分层 | 旧的重新绑定设置恢复;较早的覆盖项消失 | BindingStore.ResetAll;使用 Load(..., removeExisting: false) |
在测试中或搭配 PlayerInput 使用 InputSystem.actions | fixture 测试后变为 null;配对失败 | EditorBuildSettings.TryGetConfigObject(...);playerInput.actions |
| 每个消费方都生成一个输入类 | 重新绑定只在一个屏幕上显示 | 使用一个共享实例 |
| 运行时 asmdef 引用 editor asmdef | Editor 正常,播放器构建失败 | 使用 AssemblyGraph 检查显式引用 |
在 [SerializeReference] 背后重命名类 | 条目加载为 null | 在同一项更改中使用 [MovedFrom] |
| 信任合并输出 | 冲突字段悄然恢复为基础值 | 检查退出码 2,解决冲突,使用 InspectAsset |
交接
- 接收自 scenario-unity-expert:简报、渠道、项目事实。接收自 scenario-unity-gameplay:哪些系统需要生命值、伤害、状态机(FSM 模式位于那里)。
- 交付给 scenario-unity-ui:
RebindFlow、BindingStore、InputContext、渠道(带有RaiseDebug),供 HUD 和设置界面使用,并附带测试。交付给 scenario-unity-gameplay:IDamageable、Health、Services、GameRoot、运行时集合。交付给 scenario-unity-pipeline-automation:asmdef 布局、run_tests(..., assemblies=...)、开发版本构建、.gitignore/.gitattributes、合并 驱动程序。交付给 scenario-unity-performance:每帧 GC(scenario-unity-expertAgentProfile)、 每个对象的 Awaitable 循环。交付给 scenario-unity-web:不使用托管线程(SaveAsync 保持 同步,不使用Task.Delay)。交付给 scenario-unity-mobile:触控方案、persistentDataPath。 交接包:写入的文件、含总数的测试、含计数的审计、已验证项和假设项。
Unity 6.3 说明
- Enter Play Mode 是一个下拉选项("When entering Play Mode");6.6 的新项目默认设置为 "Reload
Scene only";6.5 新增了代码生命周期属性和
AutoStaticsCleanup。 FindObjectsByType<T>(FindObjectsSortMode.None)(排序模式从 6.4 起已弃用);[SerializeField]仅用于字段;GetInstanceID在 6.5 中会成为编译错误(EntityId); 从 6.4 起,[SerializeReference]类型必须标记为[Serializable](版本说明中有记录,但未经验证)。- 模板仅运行 Input System(旧版
Input.*会抛出异常);捆绑 Input System 1.20;OnMouseDown从 6.4 起才可与其配合使用。 Object.Destroy:已在 6000.3.21f1 上验证(LifecycleTests,2026-09-24),OnDisable会触发到每个后代对象(共四层);6.4 升级指南和版本说明称 6.4 之前的编辑器只处理 “直接子对象”,因此旧版编辑器仍应保留幂等的OnDestroy注销逻辑。- Force Text 和 Visible Meta Files 是 6.3 的默认设置(已观察到);6.6 会关闭 YAML 自动换行,并移除 "Reduce Version Control Noise"。
参考资料
references/procedures.md:A1 至 A15,包含完整代码、实时测试和 记录的结果。references/expert-notes.md:各专家的原则及时间戳、决策表、 所有观察结果。references/critique.md:自我审查标准。references/gui-paths.md:执行相同任务所需的菜单和窗口。references/sources.md:来源、凭据、URL、最佳时间戳、 修订历史。scripts/ut_arch.py、scripts/AgentKit/Architecture/*.cs、scripts/templates/:经过测试的代码。