Table of Contents

最佳实践

一些提示,帮助你为 Stride 项目构建健壮且易于维护的代码库。

提前规划与技术债务

在编写大型系统之前,请先考虑它如何与现有系统的其他部分配合。 许多系统一旦实现就会变得相互依赖,例如:

  • 这个新系统在保存和重新加载游戏会话时应该如何表现。
  • 当游戏暂停时。
  • 当加载新场景时。
  • 当切换游戏模式时。
  • 它应该读取哪些玩家输入。
  • 在多人游戏环境中,它应如何在客户端之间同步。
  • 等等。

话虽如此,在开发过程中你总会遇到一些问题,需要更改设计、重写这些系统,例如:一个无趣或不可行的功能、对游戏范围的重新评估、为了让功能尽快上线而走的捷径、初始设计不完善,等等。

游戏的设计并非一成不变;正如 Parsons School of Design 的游戏设计师 Macklin 和 Sharp 所观察到的,它会随着时间不断打磨:

设计游戏极具挑战性 […] 更雪上加霜的是,游戏设计师在游戏真正被玩到之前,其实无法真正"看到"他们的设计,而游戏在制作完成之前又无法被玩到。这正是迭代设计流程派上用场的地方。

Macklin, C., & Sharp, J. (2016). Games, design and play: A detailed approach to iterative game design. Addison-Wesley Professional.

你可能会想把这些重写工作暂时搁置一旁,因为它们对游戏没有直接贡献,但这样做会让你慢慢把自己逼进死角:为了维持问题系统勉强运行,只能不断在其周围堆砌快速补丁,结果不断积累 bug 和性能问题,而不是正面解决问题。

Frederick Brooks 是最具影响力的软件设计师之一,谈到软件的规划与重写:

[…] 问题不在于是否要构建一个试验系统然后扔掉它。你总会这么做的。唯一的问题是,你是事先计划好构建一个一次性系统,还是承诺把这个一次性系统交付给用户。这样看来,答案就清晰得多。把它交付给用户固然能争取时间,但代价是用户的痛苦、开发者在重新设计期间的分心,以及产品留下的坏名声——即便最好的重新设计也很难挽回。
因此,计划好扔掉一个吧;反正你无论如何都会这么做。

Frederick P. Brooks Jr. (1975). The Mythical Man-Month. Addison-Wesley.

过早的泛化

与上面相关的是,我们可能会走向另一个极端:过度设计系统。误导自己去添加覆盖假想情况的功能,为其扩展以适配未来的游戏,或者将其封装起来分享给他人使用。

以下是《Max Payne 3》的高级程序员 Richard Fabian 关于系统复用的看法:

通过复用旧代码来持续降低开发开销,被认为是开发的圣杯之一。[…] 不幸的事实是,你想要添加的任何有趣的新特性,很可能都与你的旧代码以及旧的数据组织方式不兼容,因此你要么重写旧代码以支持新特性,要么重写旧代码以支持新的数据布局。

Richard Fabian. Data-Oriented Design 11.7 Reusable generic code.

围绕同一主题,以下是 William Agresti 的一项调查:

一项调查结果揭示了复用在大型软件项目中可能扮演的角色——项目超出预算:40% 的开发者报告称,在 25% 或更多的情况下,他们最终不得不大幅修改或完全重写一个原本计划被复用的模块。

Agresti, W. (2011) Software Reuse: Developers' Experiences and Perceptions. Journal of Software Engineering and Applications, 4, 48-58. doi: 10.4236/jsea.2011.41006.

在开始一个新项目时,我们也可能更愿意重写这些系统。在以往项目中积累的经验,可以让我们以更简洁、更易维护或更健壮的方式来设计它们。

确实有些系统很值得复用,但我们并不是在构建中间件。我们应该问问自己:现在是不是着手泛化这个系统的合适时机,还是应该以我们的游戏为先。

AI 助手与 LLM

大语言模型和 AI 助手已经出现在大多数程序员的工具箱中。然而,和所有工具一样,它们也有缺点。请思考这句众所周知的谚语:

如果你手里只有一把锤子,看什么都像钉子。

正如这句话所暗示的,我们应该仔细考量何时使用某个工具,以免对它们变得过分热衷或过度依赖,正如下面这项研究所指出的:

AI 工具被认为有助于理解代码和增强信心,尤其是在初始开发阶段。然而,学生报告称在没有 AI 辅助的任务中难以迁移所学知识,这暴露出可能存在的过度依赖以及概念理解上的不足。

Rojas-Galeano et al. (2025). Between Tool and Trouble: Student Attitudes Toward AI in Programming Education. doi: 10.48550/arXiv.2508.05999.

在设计或编程任务上依赖 LLM 会阻碍我们在这些领域的能力成长,而这些都是我们必须通过持续练习来保持和提升的技能。下面这段摘录说明了这种依赖会如何影响我们:

[…] 对 AI 工具的过度依赖导致信心和技能保持度下降,因为学生对语法规则和构思表达越来越不上心,转而依赖 AI 来完成这些工作。

Budiyono, Herman. (2025). Exploring long-term impact of AI writing tools on independent writing skills: A case study of Indonesian language education students. International Journal of Information and Education Technology, 15(5), 1003-1013. 10.18178/ijiet.2025.15.5.2306.

我们应该用 LLM 来学习新的概念或技能,但排查复杂问题、设计突破性的系统,以及为我们的设计问题创造创新的解决方案,那是我们自己的特权。凭借足够的经验,这些正是我们比 LLM 表现得更好的领域。

明确你的系统生命周期

你所写的系统,其作用范围是什么:

  • 同一个实例是否要在应用程序的整个生命周期中使用?
  • 还是只在一局游戏期间使用?
  • 还是只在当前加载的游戏会话期间使用?
  • 在单张地图内?
  • 针对特定的游戏模式?
  • 等等……

这会确立一些预期:你构建的系统应该驻留在哪里、它如何与其他系统交互,以及它应该在何时启动和释放……

管理你系统生命周期的一些入口和出口:

  • 按需在 Services 中获取和设置
  • 作为单例脚本,配合 Start() 和 Cancel()
  • 通过游戏的 BeginRun() 和 EndRun()
  • 作为组件处理器(component processor),在相关组件类型被添加到场景时

静态、单例与其他全局项

我们强烈建议你确保游戏的全部状态都实现为根场景层次结构中组件上的实例属性。避免使用静态属性和静态对象。

这对于减少在实现管理游戏状态的系统(如存档系统或多人层)时引入的 bug 至关重要。 这样一来,这些系统就可以基于"它们所关心的一切都在根场景内"这一预期来实现;它们可以替换整个根场景并期望游戏状态被完全重置;它们可以序列化或监视这些实体的变化等等,而不用担心游戏状态在多次游玩会话之间泄漏,进而产生极难复现的问题。

当出现以下情况时,某些系统作为场景的一部分可能并不合理:

  • 这类系统保存的功能和变量会在程序运行期间持续存在,或跨越所有会话
    • 保存、加载系统,元进度追踪或成就
  • 该系统是只读的
    • 多人游戏服务器浏览器或匹配后端。不过一旦连接到某个会话就另当别论了:此时你必须持有一堆仅对该会话有效的状态,它不应泄漏到程序的其余部分,因此最好还是作为场景中某个实体上的组件。

这些限制并不会阻止你使用单例模式,你可以使用可以从任何 ScriptComponent 访问的 ServiceRegistry。

// 这是一个基本的单例类
public class MySingleton : IService
{
    public static IService NewInstance(IServiceRegistry r) => new MySingleton();

    public void DoStuff(){}
}

// 你可以在任意组件中创建并获取这个单例:
public override void Update()
{
    Services.GetOrCreate<MySingleton>().DoStuff();
}

// 当你需要把生命周期限制在场景内时,可以这样实现单例:
public class MySingleton : SyncScript, IService
{
    public override void Start()
    {
        var instance = Services.GetService<MySingleton>();
        if (instance != this)
            Entity.Remove(this); // 如果已存在另一个实例,确保移除当前这个
        else if (instance is null)
            Services.AddService(this); // 如果它是通过场景而不是通过 Services.GetOrCreate<MySingleton>() 添加的,就把它注册为服务
    }

    public override void Cancel()
    {
        base.Cancel();
        Services.RemoveService(this); // 当该组件从场景中移除时,将其从服务中移除
    }

    public override void Update() { }

    public static IService NewInstance(IServiceRegistry r)
    {
        var instance = new MySingleton();
        r.GetService<SceneSystem>().SceneInstance.RootScene.Entities.Add(new Entity{ instance });
        return instance;
    }
}

实现自定义资产

你将要构建的某些系统作为资产比作为实体更有意义,当满足以下任意条件时,考虑把它们做成资产:

  • 它跨越多个场景而存在
  • 它是只读的
  • 它不是实体定义的一部分,不存在于你的游戏世界中
  • 它应该能在编辑器中由设计师编辑

例如:

  • 玩家输入配置:在源代码中定义动作,在编辑器中分配按键,在游戏初始化时保存和加载到磁盘
  • 平衡性设置:从编辑器中调整常量和公式,以改善测试游戏时的迭代效率
  • 任务/使命:在组件中引用任务,当任务完成时解锁游戏中的地点,让设计师能够自行配置
  • 掉落表:使用一个带掉落概率的 UrlReference<Prefab> 列表,方便在多个怪物之间复用
  • 作为通用且健壮的"键"或"标识符"类型,参见本节

不要修改资产

由此引出一点:务必只在有意义时才修改资产。请记住,单个资产可能被数百个组件和系统引用,而它们未必期望该资产在运行时发生变化。严格遵守这一理念,还能确保你的游戏状态不会在加载新会话、新游戏模式或其他内容时通过这些资产发生泄漏。 例如,假设你有一个带修正值列表的斧头资产,你保存了游戏、推进了一段进度,然后添加了一个修正值,但最终重新加载到了之前的存档,那么这个修正值会带入之前的游戏状态中。

场景的小坑

游戏为你生成的默认场景是存储在内容管理器(content manager)中的那个实例,运行游戏时你修改的正是这个实例,这意味着如果你想取回初始状态的场景,必须强制内容管理器先卸载它,然后再重新加载。 如果你只是想重新生成当前场景来回滚更改,这会显得有些违反直觉。

将字符串用作键或标识符

这是一种非常常见的反模式,用作键或标识符的字符串随处可见。下面用一个简短的例子来描述这种用法: 你正在实现的任务要求玩家收集 10 个熊屁股,而你的检查方式是遍历玩家拥有的物品列表,检查物品的名称是否与 bear arses 匹配。

以下是这样做不妥的几个原因:

  • 难以维护:如果因为熊在 Freedonia 被禁止,你的物品最终改了名字,你的检查会悄无声息地失败
  • 脆弱:你的字符串不会与任何东西进行校验,拼写错误——bear ass 就无法工作,还要小心首尾空格……
  • 隐晦:不了解你的系统如何运作的设计师,可能不明白那里应该输入什么
  • 唯一性要求不明确:如果你的系统期望它们是唯一的,即你在寻找一个非常特定的"bear arse";但你完全可能有多个不同的物品都叫 bear arse
  • 难以追踪:字符串太过普遍,很难快速检索出这些字符串的所有实例/用法来为翻译、验证等不同目的建立数据库

所有这些都不可避免地会导致 bug,或者需要额外的工作来避免它们——而这些时间你完全可以用来处理游戏中其他更有趣的部分。

有几种方法可以避免这个问题,其中比较健壮的一种是依赖资产本身;参见自定义资产。

public class Item { }

// 现在在你的组件中 ...
public Item ItemToCheck; // 你可以在编辑器中指定这个引用
public int AmountRequired = 10;

public bool HasTheItem()
{
    if (Inventory.TryGetAmount(ItemToCheck, out var amount) && amount >= AmountRequired)
        return true;
    return false;
}
  • 健壮:你可以更改它的名称和路径,只要不删除它或更改其内部 ID,所有引用它的组件都会保留该引用。
  • 易于使用和理解:如果一个组件需要某个特定资产,你并没有无限的可能性,要么把它设为已有的资产,要么创建一个新资产。由于拼写错误不再成为问题,这也更加万无一失。
  • 易于追踪:每个类型都有唯一的扩展名,你可以在文件资源管理器中搜索它们,它们存在于磁盘上,因此可以整理到同一个目录中。
    • 编辑器的引用窗口会列出所有使用了所选资产的资产,当你需要把标识符换成另一个或彻底删除它时,这非常有帮助,只需处理所有引用它的资产即可。
    • 你不需要维护一份记录游戏中所有可能标识符的文档,只需在编辑器中查看它们所在的目录即可。
  • 易于扩展:你的标识符现在可以不只是标识符,你可以为它附加属性,例如一个描述,以保存关于这个键的更多信息。

避免高度间接的模式

尤其是在修改游戏状态时,事件键和异步方法带有大量隐式复杂性,因为它们在被发出信号/调用时可能不会完成。当异步恢复执行/事件键被接收时,游戏可能已经处于这样的状态:你运行的逻辑不再有效。某些实体可能已从场景中移除,背包中可能不再有该物品,玩家角色可能已经失去行动能力……

这个特点还意味着,它们的执行不属于调用者的调用栈,因此与之相关的调试问题要难查得多。

它们的生命周期也更难以推断:即便期间场景已被替换,EventKey 仍会携带信号;而在 AsyncScript 的 Execute() 之外运行时,异步操作仍会继续运行。

EventKey 的替代方案:

  • C# 事件,尽管这要求接收者直接引用发送者
  • 绑定到灵活处理器的带接口组件。把处理器加入服务注册表,调用某个方法来遍历并调用实现该处理器接口的每个组件

async 的替代方案:

  • 把你的异步重构为同步的……显然!
  • 如果无法避免使用 async:
    • 不要触碰游戏状态,只接收一些输入,输出一个由 SyncScript 读取的结果
    • 确保在等待之前始终把游戏状态留在有效状态,并在等待之后检查状态是否仍然允许继续执行该异步方法。也就是说:我们是不是突然回到主菜单了?!

你可能会发现,最后这两种做法需要大量额外的逻辑才能妥善支持,这说明你的逻辑应当重新考虑——你正在把自己逼进死角。

避免编写走捷径的扩展方法

这里专门指的是这类方法:

static Entity GetFirstChild(this Entity Entity) => Entity.Transform.Children[0].Entity;
// 或者
static void AddAsFirstChild(this Entity Entity, Entity entity) => Entity.Transform.Children.Insert(0, entity);

这是一把双刃剑:

  • 你在降低不熟悉该 API 的用户的使用门槛,但同时也在妨碍他们的成长,因为他们现在依赖你提供的捷径,而不是自己去发现 API——而后者会让他们接触到与之相关的概念和对象,更清楚地了解所有对象是如何组合在一起的。如果他们需要访问所有子对象呢?通过这个扩展方法,他们不会知道是变换组件保存着这些子对象,可以直接访问它来实现。
  • 务必确保访问你所隐藏的内容永远不容易出错,尤其是当方法名没有把这一点说清楚时。你可能减少了打字浪费的时间,但当它真的引发问题时,你很可能要花更多时间去调试。
  • 这很可能成为一条滑坡:为所呈现对象的其他属性或方法引入更多捷径,比如为实体的第二个子对象、第三个子对象创建扩展……
  • 污染智能感知(intellisense);在多数情况下这没什么问题,但集合类型就是典型的例子。通过智能感知发现扩展方法几乎是不可能的,linq 引入的扩展方法实在太多了。
  • 这可能会让用户误以为你的捷径与源 API 有所不同。

实体与组件的生命周期

Stride 有一个意想不到的特点:组件和实体被期望能够在任意多次从场景中移除并重新插入之后继续存在。这些对象永远不会被真正"销毁",它们就像其他任何 C# 对象一样,要么存在,要么超出作用域。

请确保你的组件遵守这一规则,通过 Cancel() 撤销在 Start() 中引入的任何副作用。 这个特点带来了一些好处,其中一个主要好处是:你可以临时从游戏中移除组件、实体甚至场景,并在需要时随时重新引入,而不会丢失数据,也不需要复杂的序列化步骤。

这也意味着,你应该避免编写任何自定义的"销毁"函数,以确保引擎的任何部分在任何时候都可以简单地把实体从场景中移除,并依靠你对 Cancel() 的实现来处理"销毁"时应当发生的一切。

Get 的用法

使用 Get<MyComponent> 时,请扪心自问:如果该调用返回 null,这个函数是否就无法工作?如果是这样,那么你的函数就依赖该实体上存在该组件。这是一种硬性依赖,你应该尽一切可能通知代码库的其他部分以及使用编辑器的设计师:该组件是必需的,从而避免把时间浪费在调试相关问题上。

有几种做法可以做到这一点,这里我们简单地把该组件直接作为函数的参数传入:

// 原来
public void MyFunction(Entity entity)
{
    entity.Get<MyComponent>().DoSomething();
}
// 改为
public void MyFunction(MyComponent component)
{
    component.DoSomething();
}

也可以把该组件作为可在编辑器中设置的属性:

// 原来
public void MyFunction()
{
    Entity.Get<MyComponent>().DoSomething();
}

// 改为
// 'required' 关键字会在编辑器中未设置该值时于构建时生成警告
public required MyComponent MyRequiredComponent { get; set; }

public void MyFunction()
{
    MyRequiredComponent.DoSomething();
}

读完这些之后,你可能落入的另一个陷阱是进行防御性编程:检查它是否为 null 并在为 null 时直接返回,即使其余逻辑期望发生某种变化。 这往往会让你编写远多于必要的样板逻辑,而如果你一开始就确保拥有一个有效的实例,本可以避免这些。

你可能还会考虑是否直接把相互依赖的对象合并在一起:如果这两个对象中只有一个是为了另一个的用途而存在,那么直接合并它们可能比保留两个不同的组件更合理。

另请参阅