Plant 是一个参考体验,玩家在其中种植和浇水种子,以便后续收获和出售相应的植物。

该项目侧重于在 Roblox 上开发体验时可能遇到的常见用例。在适用的情况下,您会找到关于权衡、妥协和各种实现选择的理由的说明,以便您可以为自己的体验做出最佳决策。
获取文件
- 导航到 植物 体验页面。
- 点击 ⋯ 按钮并 在 Studio 中编辑。
用例
Plant 涵盖以下用例:
- 会话数据和玩家数据持久性
- UI 视图管理
- 客户端-服务器网络
- 首次用户体验 (FTUE)
- 硬币和软币购买
此外,该项目还解决了一些较窄的问题,适用于许多体验,包括:
- 自定义与玩家相关的区域
- 管理玩家角色的移动速度
- 创建跟随角色的对象
- 检测角色所处的世界部分
请注意,在该体验中有几个用例过于简单、过于小众或未能展示有趣设计挑战的解决方案;这些用例未被涵盖。
项目结构
创建体验时的首要决策是决定如何结构化 项目,这主要包括在 数据模型 中放置特定实例的位置,以及如何组织和结构化客户端和服务器代码的入口点。
数据模型
下面的表格描述了在数据模型中放置的容器服务实例。
| 服务 | 实例类型 |
|---|---|
| Workspace | 包含静态模型,表示 3D 世界,特别是那些不属于任何玩家的世界部分。您无需在运行时动态创建、修改或销毁这些实例,因此保持它们在此处是可以接受的。 还有一个空的 Folder,玩家的农场模型将在运行时添加到该文件夹中。 |
| Lighting | 气氛和光照效果。 |
| ReplicatedFirst | 包含显示加载屏幕和初始化体验所需的最小实例子集。放在 ReplicatedFirst 中的实例越多,在 ReplicatedFirst 中运行代码之前的复制等待时间就越长。
|
| ReplicatedStorage | 作为所有需要在客户端和服务器上访问的实例的存储容器。
|
| ServerScriptService | 包含一个 Script,作为项目中所有服务器端代码的入口点。 |
| ServerStorage | 作为所有不需要复制到客户端的实例的存储容器。
|
| SoundService | 包含用于体验中的音效的 Sound 对象。在 SoundService 下,这些 Sound 对象没有位置,并且不会在 3D 空间中模拟。 |
入口点
大多数项目将代码组织在可重用的 ModuleScripts 中,且可以在整个代码库中导入。ModuleScripts 是可重用的,但它们不能单独执行;它们需要由 Script 或 LocalScript 导入。许多 Roblox 项目将有大量 Script 和 LocalScript 对象,每个对象与体验中的某种行为或特定系统相关,创造多个入口点。
在 Plant 微游戏中,通过单个 LocalScript 实现了一种不同的方法,作为所有客户端代码的入口点,以及一个单独的 Script,作为所有服务器代码的入口点。对于您的项目,正确的方法取决于您的需求,但单一入口点能够提供更好的顺序控制,以确保系统的执行顺序。
以下列表描述了两种方法的权衡:
- 一个 Script 和一个 LocalScript 分别覆盖服务器和客户端代码。
- 更好地控制不同系统的启动顺序,因为所有代码都是从一个脚本初始化的。
- 可以在系统之间以引用方式传递对象。
高级系统架构
项目中的顶级系统如下所述。这些系统中的一些比其他系统复杂得多,在许多情况下,它们的功能被抽象到了其他类的层次结构中。

这些系统中的每一个都是一个“单例”,即非实例化类,而是通过相关的客户端或服务器 start 脚本进行初始化。您可以稍后在本指南中阅读关于 单例模式 的更多信息。
服务器
以下系统与服务器相关。
| 系统 | 描述 |
|---|---|
| 网络 |
|
| PlayerDataServer |
|
| 市场 |
|
| CollisionGroupManager |
|
| FarmManagerServer |
|
| PlayerObjectsContainer |
|
| TagPlayers |
|
| FtueManagerServer |
|
| CharacterSpawner |
|
客户端
以下系统与客户端相关。
| 系统 | 描述 |
|---|---|
| 网络 |
|
| PlayerDataClient |
|
| MarketClient |
|
| LocalWalkJumpManager |
|
| FarmManagerClient |
|
| UISetup |
|
| FtueManagerClient |
|
| CharacterSprint |
|
客户端-服务器通信
大多数 Roblox 体验涉及客户端和服务器之间的某种通信。这可能包括客户端请求服务器执行某个操作,以及服务器将更新复制到客户端。
在该项目中,通过限制 RemoteEvent 和 RemoteFunction 对象的使用,以尽可能保持客户端-服务器通信的通用性,从而减少需要跟踪的特殊规则的数量。此项目使用以下方法,按优先顺序排列:
通过玩家数据系统复制
玩家数据系统 允许将数据与玩家关联,该数据在保存会话之间保持持久性。该系统提供从客户端到服务器的复制,以及一组可以用于查询数据和订阅变化的 API,因而非常适合从服务器向客户端复制玩家状态的变化。
例如,与其触发一个定制的 UpdateCoins RemoteEvent 来告诉客户端它有多少硬币,您可以调用以下内容,并让客户端通过 PlayerDataClient.updated 事件进行订阅。
PlayerDataServer.setValue(player, "coins", 5)
当然,这仅对从服务器到客户端的复制和希望在会话之间保持的值有用,但这适用于该项目中的许多情况,包括:
- 当前的 FTUE 阶段
- 玩家库存
- 玩家拥有的硬币数量
- 玩家农场的状态
通过属性复制
在服务器需要将特定于给定 Instance 的自定义值复制到客户端的情况下,您可以使用 属性。Roblox 会自动复制属性值,因此您无需维护任何代码路径来复制与对象相关联的状态。另一个优势是该复制与实例本身一起发生。
这对于在运行时创建的实例特别有用,因为在将新实例抚育至数据模型之前设置的属性将与实例一同原子复制。这可以避免编写代码来“等待”通过 RemoteEvent 或 StringValue 复制额外数据的需要。
您也可以直接从客户端或服务器读取数据模型中的属性,使用 GetAttribute() 方法,并通过 GetAttributeChangedSignal() 方法订阅变化。在 Plant 项目中,这种方法用于,除了其他事情外,将植物的当前状态复制到客户端。
通过标签复制
CollectionService 允许您将字符串标签应用于 Instance。这对于对实例进行分类并将该分类复制到客户端非常有用。
例如,CanPlant 标签在服务器上应用,以向客户端标示给定的花盆可以种植植物。
通过网络模块直接消息
对于不适用之前任何选项的情况,您可以使用 网络 模块进行自定义网络调用。项目中唯一允许客户端与服务器通信的选项,因此最适合用于传输客户端请求和接收服务器响应。
Plant 使用直接网络调用处理多种客户端请求,包括:
- 浇水植物
- 种植种子
- 购买物品
这种方法的缺点是每个单独消息需要一些定制配置,这可能增加项目的复杂性,但在服务器到客户端通信时已经尽可能避免了这种情况。
类和单例
Plant 项目中的类,像 Roblox 上的实例一样,可以被创建和销毁。其类语法受到 Lua 的惯用方法 面向对象编程 的启发,并进行了一些更改以支持 严格类型检查。
实例化
项目中的许多类与一个或多个 Instances 相关联。给定类的对象使用 new() 方法创建,这与在 Roblox 中使用 Instance.new() 创建实例的方式一致。
通常对那些在数据模型中具有物理表示的对象使用此模式,并且该类扩展了其功能。一个好的例子是 BeamBetween,它在两个给定的 Attachment 对象之间创建一个 Beam 对象,并保持这些附件的方向,使光束始终面朝上。可以从 ReplicatedStorage 中克隆这些实例,或者作为参数传递给 new() 并在对象中存储在 self 下。
对应的实例
正如上面提到的,该项目中的许多类都有数据模型表示,即与类对应并由它操作的实例。
代码通常选择在类对象被实例化时不创建这些实例,而是选择 Clone() 从 ReplicatedStorage 或 ServerStorage 中存储的预制版本。尽管可以序列化这些实例的属性并在类的 new() 函数中从头开始创建它们,但这样会使编辑这些对象变得非常繁琐,并使读者更难理解。此外,克隆实例通常比在运行时创建新实例并自定义其属性更快。
组合
尽管可以通过 元表 在 Luau 中实现继承,但该项目选择允许类通过 组合 进行扩展。在通过组合将类组合在一起时,“子”对象在类的 new() 方法中实例化,并作为成员包含在 self 下。
有关该示例的更多信息,请参见 CloseButton 类,它包装了 Button 类。
清理
类似于可以使用 Destroy() 方法销毁 Instance,可以实例化的类也可以被销毁。项目类的析构方法是 destroy(),为确保代码库方法中的 camelCase 一致性而使用小写的 d,以区分项目的类和 Roblox 实例。
destroy() 方法的作用是销毁对象创建的任何实例,断开任何连接,并调用任何子对象的 destroy()。这在连接中尤其重要,因为具有活动连接的实例不会被 Luau 垃圾收集器清除,即使没有对该实例或与该实例的连接的引用。
单例
单例,顾名思义,是只能存在一个对象的类。它们是项目中 Roblox 的 服务 的等价物。与其在 Luau 代码中存储对单例对象的引用并传递它,不如 Plant 利用需要 ModuleScript 时缓存其返回值的事实。这意味着从不同地方要求相同的单例 ModuleScript 始终提供相同的返回对象。 唯一的例外是如果不同的环境(客户端或服务器)访问 ModuleScript。
单例与可实例化类的区别在于它们没有 new() 方法。相反,通过 ModuleScript 直接返回对象以及其方法和状态。由于单例不是实例化的,因此不使用 self 语法,而是使用点(.)而不是冒号(:)调用方法。
严格类型推断
Luau 支持渐进式类型,这意味着您可以自由地向某些或所有代码添加可选的类型定义。在本项目中,strict 类型检查用于每个脚本。对于 Roblox 的 脚本分析 工具来说,这是最严格的选项,因此最有可能在运行时之前抓住类型错误。
类型化类语法
在 Lua 中创建类的既定方法 文档齐全,然而它并不适合强 Luau 类型。在 Luau 中,获取类类型的最简单方法是 typeof() 方法:
type ClassType = typeof(Class.new())
这样做是可行的,但当您的类使用在运行时仅存在的值初始化时,例如 Player 对象,这并不是很有用。此外,在惯用 Lua 类语法中做出的假设是,声明在类 self 上的方法总是该类的一个实例;这是类型推断引擎无法做出的假设。
为了支持严格类型推断,Plant 项目使用与惯用 Lua 类语法在许多方面不同的解决方案,其中一些可能会感觉不直观:
- self 的定义在类型声明和构造函数中都是重复的。这引入了可维护性负担,但如果两个定义不同步,会发出警告。
- 类方法用点声明,以便将 self 显式声明为 ClassType 类型。方法仍然可以像预期的那样用冒号调用。
--!strict
local MyClass = {}
MyClass.__index = MyClass
export type ClassType = typeof(setmetatable(
{} :: {
property: number,
},
MyClass
))
function MyClass.new(property: number): ClassType
local self = {
property = property,
}
setmetatable(self, MyClass)
return self
end
function MyClass.addOne(self: ClassType)
self.property += 1
end
return MyClass
在逻辑保护后转换类型
在撰写本文时,值的类型在保护条件语句之后不会被缩小。例如,在以下保护之后,optionalParameter 的类型并未缩小为 number。
--!strict
local function foo(optionalParameter: number?)
if not optionalParameter then
return
end
print(optionalParameter + 1)
end
为避免这种情况,在这些保护后使用显式转换类型创建新变量。
--!strict
local function foo(optionalParameter: number?)
if not optionalParameter then
return
end
local parameter = optionalParameter :: number
print(parameter + 1)
end
遍历数据模型层次
在某些情况下,代码库需要遍历在运行时创建的对象树的数据模型层次。这对类型检查提出了有趣的挑战。在撰写本文时,无法将通用数据模型层次定义为类型。因此,在仅提供顶层实例类型的情况下,只能获得数据模型结构的类型信息。
解决这一挑战的一种方法是转换为 any,然后进行完善。例如:
local function enableVendor(vendor: Model)
local zonePart: BasePart = (vendor :: any).ZonePart
end
这种方法的问题在于它影响可读性。相反,项目使用了一个名为 getInstance 的通用模块,用于遍历数据模型层次,它在内部转换为 any。
local function enableVendor(vendor: Model)
local zonePart: BasePart = getInstance(vendor, "ZonePart")
end
随着类型引擎对数据模型的理解不断演变,可能不再需要像这样的模式。
用户界面
Plant 包括各种复杂和简单的 2D 用户界面。这些包括非交互式的抬头显示(HUD)项目,如硬币计数器,以及复杂的交互式菜单,如商店。
UI 方法
您可以将 Roblox UI 大致比作 HTML DOM,因为它是描述用户应该看见的对象层次结构。创建和更新 Roblox UI 的方法大致分为 命令式 和 声明式 实践。
| 方法 | 优缺点 |
|---|---|
| 命令式 | 在命令式方法中,UI 被视为 Roblox 上的其他实例层次。UI 结构在运行时之前在 Studio 中创建并添加到数据模型中,通常直接在 StarterGui 中。然后,在运行时,代码操作 UI 的特定部分,以反映创作者需要的状态。 这种方法有一些优点。您可以在 Studio 中从头开始创建 UI 并将其存储在数据模型中。这是一个简单而直观的编辑体验,可以加速 UI 创建。由于命令式 UI 代码只关注需要更改的内容,因此使简单的 UI 更改容易实现。 一个显著的缺点是,由于命令式 UI 方法需要以转换的形式手动实现状态,因此复杂的状态表示可能会变得很难找到和调试。在开发命令式 UI 代码时,尤其是在状态和 UI 因多个更新以意外顺序相互作用而变得不同步时,常常会出现错误。 命令式方法的另一个挑战是,将 UI 拆分为有意义的组件变得更加困难,这些组件可以一次声明并重用。由于整个 UI 树在编辑时声明,因此常见模式可能在数据模型的多个部分中重复。 |
| 声明式 | 在声明式方法中,UI 实例所需的状态被明确声明,该状态的有效实现由 Roact 或 Fusion 等库进行抽象。 这种方法的优点是状态的实现变得微不足道,您只需描述希望 UI 的外观。这使得识别和解决错误容易得多。 关键缺点是必须在代码中声明整个 UI 树。像 Roact 和 Fusion 这样的库有语法来简化此过程,但这仍然是一个费时的过程,并且在组合 UI 时是一种不太直观的编辑体验。 |
Plant 使用 命令式 方法,认为直接显示转换提供了对 Roblox 上如何创建和操作 UI 更有效的概述。这在声明式方法中是不可行的。一些重复的 UI 结构和逻辑也被抽象为可重用的 组件,以避免命令式 UI 设计中的常见陷阱。
高级架构

层和组件
在 Plant 中,所有 UI 结构都是 Layer 或 Component。
- Layer 被定义为一个顶级组单例,将 ReplicatedStorage 中的预制 UI 结构包装在一起。层可能包含多个组件,或者它可能完全封装自己的逻辑。层的例子有库存菜单或抬头显示中的硬币数量指示器。
- Component 是一个可重用的 UI 元素。当一个新的组件对象被实例化时,它会克隆来自 ReplicatedStorage 中的预制模板。组件本身可能包含其他组件。组件的例子有通用按钮类或项目列表的概念。
视图处理
一个常见的 UI 管理问题是视图处理。该项目有一系列菜单和 HUD 项目,其中一些监听用户输入,需要仔细管理它们的可见性或启用状态。
Plant 使用其 UIHandler 系统来处理 UI 层何时应该可见或不可见。体验中的所有 UI 层被分类为 HUD 或 菜单,并按照以下规则管理它们的可见性:
- 可以切换 菜单 和 HUD 层的启用状态。
- 仅在没有启用的 菜单 层时显示启用的 HUD 层。
- 启用的 菜单 层存储在堆栈中,并且一次只有一个 菜单 层可见。当启用 菜单 层时,它被插入到堆栈的最前面,并被显示。当 菜单 层被禁用时,它从堆栈中移除,队列中的下一个启用的 菜单 层被显示。
这种方法是直观的,因为它允许以历史方式导航菜单。如果一个菜单是从另一个菜单打开的,关闭新菜单将再次显示旧菜单。
UI 层单例向 UIHandler 注册自己,并获得一个信号,该信号在其可见性应该改变时触发。
深入阅读
通过对 Plant 项目的全面概述,您可能想探讨以下指南,这些指南更深入探讨相关概念和主题。