植物参考项目

*此内容使用人工智能(Beta)翻译,可能包含错误。若要查看英文页面,请点按 此处

植物 是一个参考游戏,玩家在其中种植和浇水种子,以便后续收获和出售所产生的植物。

植物项目横幅

该项目专注于您在 Roblox 上开发游戏时可能遇到的常见用例。在适用的情况下,您会发现有关权衡、妥协和各种实现选择的理由的说明,以便您为自己的游戏做出最佳决策。

获取文件

  1. 导航到 植物 游戏页面。
  2. 点击 按钮并选择 在工作室中编辑

用例

植物 涵盖以下用例:

  • 会话数据和玩家数据持久性
  • UI 视图管理
  • 客户端-服务器网络
  • 首次用户体验 (FTUE)
  • 硬币和软币购买

此外,该项目解决了一些适用于许多游戏的更狭窄的问题集,包括:

  • 自定义与玩家相关的地方区域
  • 管理玩家角色的移动速度
  • 创建一个跟随角色的对象
  • 检测角色所在的世界部分

请注意,该游戏中有几个用例过于小、过于小众,或未能展示有趣的设计挑战的解决方案;这些用例未被涵盖。

项目结构

创建游戏时的第一个决策是决定如何构建 项目,这主要包括在 数据模型 中放置特定实例的位置以及如何组织和构建客户端和服务器代码的入口点。

数据模型

下表描述了数据模型中实例放置的容器服务。

服务实例类型
Workspace

包含表示 3D 世界的静态模型,特别是不属于任何玩家的世界部分。您不需要在运行时动态创建、修改或销毁这些实例,因此将它们留在这里是可以接受的。

还有一个空的 Folder,玩家的农场模型将在运行时添加到其中。

Lighting

气氛和光照效果。

ReplicatedFirst

包含显示加载屏幕和初始化游戏所需的最小实例子集。放置在 ReplicatedFirst 中的实例越多,代码在 ReplicatedFirst 中运行之前的复制等待时间就越长。

  • Instances 文件夹中存在加载屏幕 GUI。
  • Source 文件夹中存在加载屏幕代码和等待其余游戏加载所需的代码。start LocalScript 是项目中所有客户端代码的入口点。
ReplicatedStorage

作为所有需要在客户端和服务器上访问的实例的存储容器。

  • Dependencies 文件夹中存在项目使用的一些第三方库。
  • Instances 文件夹中存在各种预制实例。
  • Source 文件夹中存在所有 需要在加载过程中访问的代码,这些代码需要从客户端和服务器访问。
ServerScriptService

包含一个 Script,作为项目中所有服务器端代码的入口点。

ServerStorage

作为所有不需要复制到客户端的实例的存储容器。

  • Instances 文件夹中存在一个模板 Farm 模型。玩家加入游戏时会将其副本放置在 Workspace 中,并将其复制到所有玩家。
  • Source 文件夹中存在所有仅限于服务器的代码。
SoundService

包含用于游戏中的音效的 Sound 对象。在 SoundService 下,这些 Sound 对象没有位置,并且不在 3D 空间中模拟。

入口点

大多数项目在可重用的 ModuleScripts 内组织代码,这些模块可以在整个代码库中导入。ModuleScripts 是可重用的,但它们不能单独执行;它们需要由 ScriptLocalScript 导入。许多 Roblox 项目将有大量的 ScriptLocalScript 对象,每个对象与游戏中的某个行为或特定系统相关,创建多个入口点。

对于 植物 微型游戏,采用了不同的方法,通过一个 LocalScript 作为所有客户端代码的入口点,以及一个 Script 作为所有服务器代码的入口点。您项目的正确方法取决于您的需求,但单一入口点提供了对系统执行顺序的更大控制。

以下列表描述了这两种方法的权衡:

  • 一个 Script 和一个 LocalScript 分别覆盖服务器和客户端代码。
  • 对不同系统启动顺序的更大控制,因为所有代码都是从单个脚本初始化的。
  • 可以通过引用在系统之间传递对象。

高级系统架构

项目中的顶级系统如下所述。这些系统中的一些比其他系统复杂得多,在许多情况下,它们的功能在其他类的层次结构中进行了抽象。

植物项目系统架构图

这些系统中的每一个都是一个“单例”,因为它是一个不可实例化的类,而是由相关的客户端或服务器 start 脚本初始化。您可以在本指南后面的 单例模式 中了解更多信息。

服务器

以下系统与服务器相关。

系统描述
网络
  • 创建所有 RemoteEventRemoteFunction 实例。
  • 暴露用于发送和监听来自客户端消息的方法。
  • 对运行时从客户端接收的参数进行类型验证。
PlayerDataServer
  • 使用 DataStoreService 保存和加载持久的玩家数据。
  • 在内存中存储玩家数据并将变更复制到客户端。
  • 暴露信号和方法以订阅、查询和更新玩家数据。
市场
  • 处理来自客户端的软币交易。
  • 暴露一个方法以出售收获的植物。
CollisionGroupManager
  • 将玩家角色模型分配到 碰撞组
  • 配置碰撞组,以便玩家角色无法与植物手推车发生碰撞。
FarmManagerServer
  • 当玩家加入游戏时,从他们的玩家数据重新创建玩家的农场模型。
  • 当玩家离开时移除农场模型。
  • 当玩家的农场发生变化时更新玩家数据。
  • 暴露一个方法以访问与给定玩家相关的 Farm 类。
PlayerObjectsContainer
  • 创建与玩家生命周期相关的各种对象,并提供检索这些对象的方法。
TagPlayers
FtueManagerServer
  • 在 FTUE 期间,执行每个阶段并等待其完成。
CharacterSpawner
  • 当角色死亡时重新生成角色。请注意,Players.CharacterAutoLoads 已被禁用,以便在玩家数据加载完成之前暂停生成。

客户端

以下系统与客户端相关。

系统描述
网络
  • 等待服务器创建所有 RemoteEventRemoteFunction 实例。
  • 暴露用于发送和监听与服务器之间消息的方法。
  • 强制执行运行时参数类型验证。
  • 在远程函数上运行 pcall()
PlayerDataClient
  • 在内存中存储本地玩家的数据。
  • 暴露用于查询和订阅玩家数据变化的方法和信号。
MarketClient
  • 暴露一个方法以请求服务器为软币购买物品。
LocalWalkJumpManager
  • 暴露方法以通过乘数修改角色的 WalkSpeedJumpHeight,以避免从多个地方修改这些值时发生冲突。
FarmManagerClient
  • 监听特定的 CollectionService 标签应用于实例,并创建“组件”以附加行为到这些实例。“组件”是指在 CollectionService 标签添加到实例时创建的类,并在标签被移除时销毁;这些用于农场中的 CTA 提示和各种类向玩家传达农场状态。
UISetup
  • 初始化所有 UI 层。
  • 配置某些层仅在世界的物理部分可见。
  • 为启用菜单时的特殊相机效果连接。
FtueManagerClient
  • 在客户端配置 FTUE 阶段。
CharacterSprint
  • 使用 LocalWalkJumpManager 在玩家角色不在其农场时增加 WalkSpeed

客户端-服务器通信

大多数 Roblox 游戏涉及客户端和服务器之间的某种通信。这可能包括客户端请求服务器执行某个操作,以及服务器将更新复制到客户端。

在该项目中,客户端-服务器通信尽可能保持通用,通过限制 RemoteEventRemoteFunction 对象的使用,以减少需要跟踪的特殊规则数量。该项目使用以下方法,按优先顺序排列:

通过玩家数据系统进行复制

玩家数据系统 允许将数据与玩家关联,这些数据在保存会话之间持久存在。该系统提供从客户端到服务器的复制以及一组可用于查询数据和订阅更改的 API,使其非常适合从服务器到客户端复制玩家状态的更改。

例如,与其触发一个定制的 UpdateCoins RemoteEvent 来告诉客户端它有多少硬币,您可以调用以下内容并让客户端通过 PlayerDataClient.updated 事件订阅它。

PlayerDataServer.setValue(player, "coins", 5)

当然,这仅对服务器到客户端的复制和您希望在会话之间持久的值有用,但这适用于项目中的许多情况,包括:

  • 当前 FTUE 阶段
  • 玩家库存
  • 玩家拥有的硬币数量
  • 玩家农场的状态

通过属性进行复制

在服务器需要将特定于给定 Instance 的自定义值复制到客户端的情况下,您可以使用 属性。Roblox 会自动复制属性值,因此您无需维护任何代码路径来复制与对象相关的状态。另一个优点是,这种复制与实例本身一起发生。

这对于在运行时创建的实例特别有用,因为在将新实例的属性设置为父级之前设置的属性将与实例本身原子地复制。这避免了编写代码以“等待”通过 RemoteEventStringValue 复制额外数据的需要。

您还可以直接从数据模型中读取属性,无论是从客户端还是服务器,使用 GetAttribute() 方法,并使用 GetAttributeChangedSignal() 方法订阅更改。在 植物 项目中,这种方法用于复制植物当前状态到客户端等其他内容。

通过标签进行复制

CollectionService 允许您将字符串标签应用于 Instance。这对于对实例进行分类并将该分类复制到客户端非常有用。

例如,CanPlant 标签在服务器上应用,以向客户端表明给定的花盆能够接收植物。

通过网络模块直接消息

对于没有上述选项适用的情况,您可以通过 网络 模块使用自定义网络调用。这是项目中唯一允许客户端与服务器通信的选项,因此对于传输客户端请求和接收服务器响应最为有用。

植物 使用直接网络调用处理各种客户端请求,包括:

  • 浇水植物
  • 种植种子
  • 购买物品

这种方法的缺点是每个单独的消息需要一些定制配置,这可能会增加项目的复杂性,尽管在可能的情况下已尽量避免这种情况,特别是在服务器到客户端的通信中。

类和单例

植物 项目中的类,像 Roblox 上的实例一样,可以创建和销毁。其类语法受到习惯用法 Lua 方法的启发,以支持 面向对象编程,并进行了一些更改以启用 严格类型检查 支持。

实例化

项目中的许多类与一个或多个 Instances 相关。给定类的对象使用 new() 方法创建,这与在 Roblox 中使用 Instance.new() 创建实例的方式一致。

这种模式通常用于在数据模型中具有物理表示的对象,并扩展其功能。一个很好的例子是 BeamBetween,它在两个给定的 Attachment 对象之间创建一个 Beam 对象,并保持这些附件的方向,使光束始终朝上。这些实例可以从 ReplicatedStorage 中的预制版本克隆,或作为参数传递给 new() 并存储在对象的 self 下。

对应实例

如上所述,项目中的许多类都有数据模型表示,与类对应的实例并由其操作。

与其在实例化类对象时创建这些实例,代码通常选择 Clone()ReplicatedStorageServerStorage 中存储的预制版本。尽管可以序列化这些实例的属性并在类的 new() 函数中从头开始创建它们,但这样做会使编辑对象变得非常繁琐,并使读者更难解析。此外,克隆实例通常比在运行时创建新实例并自定义其属性的操作要快。

组合

尽管在 Luau 中可以使用 元表 实现继承,但该项目选择通过 组合 允许类相互扩展。通过组合结合类时,“子”对象在类的 new() 方法中实例化,并作为成员包含在 self 下。

有关此操作的示例,请参见 CloseButton 类,它包装了 Button 类。

清理

类似于如何使用 Destroy() 方法销毁 Instance,可以实例化的类也可以被销毁。项目类的析构方法是 destroy(),以保持代码库方法中的 camelCase 一致性,并区分项目的类和 Roblox 实例。

destroy() 方法的作用是销毁对象创建的任何实例,断开任何连接,并在任何子对象上调用 destroy()。这对于连接特别重要,因为具有活动连接的实例不会被 Luau 垃圾收集器清理,即使没有对实例或与实例的连接的引用。

单例

单例,顾名思义,是只能存在一个对象的类。它们是项目中与 Roblox 的 服务 相对应的类。与其在 Luau 代码中存储对单例对象的引用并传递它,不如 植物 利用要求 ModuleScript 缓存其返回值的事实。这意味着从不同地方要求相同的单例 ModuleScript 一致地提供相同的返回对象。 唯一的例外是如果不同的环境(客户端或服务器)访问 ModuleScript

单例与可实例化类的区别在于它们没有 new() 方法。相反,对象及其方法和状态通过 ModuleScript 直接返回。由于单例未被实例化,因此不使用 self 语法,而是使用点(.)而不是冒号(:)调用方法。

严格类型推断

Luau 支持渐进类型,这意味着您可以自由地为部分或全部代码添加可选类型定义。在该项目中,所有脚本都使用 strict 类型检查。这是 Roblox 的 脚本分析 工具的最不宽松选项,因此最有可能在运行时之前捕获类型错误。

类型化类语法

在 Lua 中创建类的既定方法 文档齐全,但不太适合强 Luau 类型。在 Luau 中,获取类类型的最简单方法是 typeof() 方法:

type ClassType = typeof(Class.new())

这有效,但在您的类使用仅在运行时存在的值(例如 Player 对象)进行初始化时并不十分有用。此外,习惯用法 Lua 类语法中做出的假设是,声明在类 self 上的方法将始终是该类的一个实例;这是类型推断引擎无法做出的假设。

为了支持严格类型推断,植物 项目使用了一种与习惯用法 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

随着类型引擎对数据模型的理解不断发展,可能不再需要像这样的模式。

用户界面

植物 包含各种复杂和简单的 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 实例的期望状态被明确声明,而该状态的高效实现则由 RoactFusion 等库抽象化。

这种方法的优点是状态的实现变得微不足道,您只需描述希望 UI 的外观。这使得识别和解决错误变得显著容易。

主要缺点是必须在代码中声明整个 UI 树。像 Roact 和 Fusion 这样的库具有简化此过程的语法,但这仍然是一个耗时的过程,并且在组合 UI 时编辑体验不够直观。

植物 使用 命令式 方法,认为直接显示变换提供了更有效的概述,说明了如何在 Roblox 上创建和操作 UI。这在声明式方法中是无法实现的。一些重复的 UI 结构和逻辑也被抽象为可重用的 组件,以避免命令式 UI 设计中的常见陷阱。

高级架构

植物项目 UI 架构图

层和组件

植物 中,所有 UI 结构都是 LayerComponent

  • Layer 被定义为一个顶级分组单例,包装 ReplicatedStorage 中的预制 UI 结构。一个层可以包含多个组件,或者它可以完全封装自己的逻辑。层的示例包括库存菜单或抬头显示中的硬币数量指示器。
  • Component 是一个可重用的 UI 元素。当实例化一个新的组件对象时,它会从 ReplicatedStorage 克隆一个预制模板。组件本身可能包含其他组件。组件的示例包括通用按钮类或物品列表的概念。

视图处理

一个常见的 UI 管理问题是视图处理。该项目有一系列菜单和 HUD 项目,其中一些监听用户输入,并需要仔细管理它们何时可见或启用。

植物 通过其 UIHandler 系统来解决此问题,该系统管理 UI 层何时应该可见或不可见。游戏中的所有 UI 层被分类为 HUDMenu,并通过以下规则管理其可见性:

  • MenuHUD 层的启用状态可以切换。
  • 仅当没有启用的 Menu 层时,启用的 HUD 层才会显示。
  • 启用的 Menu 层存储在堆栈中,并且一次只能显示一个 Menu 层。当启用一个 Menu 层时,它会插入到堆栈的前面并显示。当禁用一个 Menu 层时,它会从堆栈中移除,并显示队列中下一个启用的 Menu 层。

这种方法是直观的,因为它允许通过历史记录导航菜单。如果从另一个菜单打开一个菜单,关闭新菜单将再次显示旧菜单。

UI 层单例向 UIHandler 注册,并提供一个信号,当其可见性应更改时触发。

进一步阅读

从对 植物 项目的全面概述中,您可能想要探索以下指南,这些指南更深入地探讨相关概念和主题。

  • 客户端-服务器模型 — Roblox 中客户端-服务器模型的概述。
  • Luau — 关于 Luau 的详细信息,这是 Roblox 创建的脚本语言,源自 Lua 5.1
  • 远程事件和回调 — 关于跨客户端-服务器边界进行通信的远程网络事件和回调的所有信息。
  • UI — 关于 Roblox 上用户界面对象和设计的详细信息。
©2026 Roblox Corporation、Roblox、Roblox 标志及 Powering Imagination 是我们在美国及其他国家或地区的注册与未注册商标。