mcpField
R
roblox-dev

roblox-dev

github.com/afrxo开发1262 次安装26 周前更新
代码仓库

关于 roblox-dev

roblox-dev 是面向 Roblox 和 Luau 项目的开发 Skill,聚焦引擎运行机制与编码实践。内容涵盖复制、脚本、数据服务和性能,并提供深入主题的参考文件。

主要功能

  1. 01说明 Roblox 运行时行为
  2. 02指导脚本类型与存放位置
  3. 03遵循服务器权威设计原则
  4. 04介绍复制机制与远程调用校验
  5. 05提供进阶主题参考资料

适用场景

  1. 01审查 Roblox 游戏架构
  2. 02实现客户端与服务器交互
  3. 03处理复制对象与信号
  4. 04查阅数据存储或流式加载规则

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/afrxo/roblox-agent-skills --skill roblox-dev

安装后在 Claude Code 中输入/roblox-dev确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

examples/
.gitkeep
character-lifecycle.luau
connection-tracker.luau
datastore-session.luau
remote-handler.luau
services-header.luau
references/
buffer-library.md
cloud-services.md
datastore-rules.md
parallel-luau.md
performance-optimization.md
query-descendants.md
replication-model.md
streaming-enabled.md

详细介绍

Roblox 引擎

Roblox 平台在运行时的行为。资料来源:create.roblox.com/docs(API 和概念);devforum.roblox.com(工程师的说明和 RFC)。

深入了解:


集成政策

此技能介绍推荐模式;它不授权重写现有代码。将规则应用于新代码,以及用户明确要求重构的代码。应用技能默认设置前,请先匹配代码库现有风格(大小写、缩进、引号风格、模块结构)。不要引入项目尚未使用的工具;遇到冲突时指出一次,并默认采用现有模式。

完整规则:../../shared/integration-policy.md。


原则

  • 服务器具有权威性。 客户端可能撒谎。任何会影响游戏状态的内容(货币、物品栏、伤害、可信的位置数据)都应由服务器负责。客户端表达意图;服务器负责验证并应用。
  • 状态所有权是排他的。 每份状态都有唯一所有者——服务器、控制该状态的客户端,或复制机制。两个写入者就意味着竞争条件。
  • 复制是异步的。 在服务器端创建的内容会在后续帧才出现在客户端,而非同一帧。在客户端创建的内容不会复制到服务器。
  • 让出执行权会产生可观察影响。 事件处理程序中的让出执行权调用会在其间运行其他处理程序。实际顺序未必如代码所写。
  • 引用 API 前先核实。 Roblox 的 API 范围庞大且不断变化。与其编造属性名称,不如说明不确定性。

服务 — 对所有服务使用 GetService

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")
local Players = game:GetService("Players")
  • 始终使用 game:GetService("Name")。不要使用 game.ServiceName。
    • 有些服务默认无法在脚本运行时使用;GetService 会创建这些服务,而通过点索引访问会报错。
    • 有些服务的类名与其访问器名称不同(例如,RunService 在内部名为“Run Service”)。
  • 优先使用 GetService 获取 Workspace,而不是小写的 workspace 全局变量。 两种方式都可用;这个全局变量早于 GetService,为保持兼容而存在。统一使用 GetService,可以让所有服务访问方式一致,并将拼写错误作为未定义变量错误暴露出来。
  • 将 GetService 调用放在文件顶部,按字母顺序排列,不要散落在后面的代码中。

DataModel 和实例

  • DataModel 是以 game 为根节点的树。服务是顶层子项,其他所有内容都是实例。
  • 只要 Parent = nil 且没有脚本持有引用,实例就仍然存活。设置 Parent = nil 不等于销毁。 使用 :Destroy() 断开事件连接、将实例标记为已销毁,并将 Parent 设为 nil。
  • :Destroy() 不可逆。 已销毁的实例无法重新设置父级;继续访问其属性会报错。
  • :Clone() 要求 Archivable = true(默认值)。克隆模型会包含其所有后代;兄弟实例之间的引用不会重新绑定。
查询后代
  • :GetChildren() — 仅直接子级。

  • :GetDescendants() — 完整子树,不过滤。对于小型树开销低,对于大型树开销高(例如流式加载的 Workspace)。

  • :FindFirstChild(name) / :FindFirstChild(name, true) — 第二个参数表示递归查找。

  • :FindFirstChildOfClass(class) / :FindFirstChildWhichIsA(class) — 按类筛选直接子级。

  • :QueryDescendants(selector: string) → { Instance }。在引擎端执行经过筛选的后代查询,支持类似 CSS 的选择器语法(ClassName、.Tag、#Name、[prop = val]、[$attr]、[$attr = val]、组合符 > >> ,、伪类 :not(...) :has(...))。对于大型树,优先使用此方法,而不是 GetDescendants + Luau 端筛选。线程不安全。完整语法和示例见 references/query-descendants.md。

    Workspace:QueryDescendants("MeshPart.SwordPart")              -- MeshParts tagged SwordPart
    Workspace:QueryDescendants("Model >> [$OnFire = true]")       -- attribute-matching descendants of any Model
    Workspace:QueryDescendants(":not(SpotLight, PointLight)")     -- excluding lights
    
WaitForChild 规则
  • 在客户端,复制容器(例如 ReplicatedStorage、Workspace)的子级在第一帧可能尚不存在。对于任何不是你自己放入其中的对象,都使用 :WaitForChild("Name")。
  • 在服务器,你在 Studio 中放置的每个 Instance 在脚本启动时都已存在;使用 :FindFirstChild 即可。
  • 生产代码中绝不要在不设置超时的情况下调用 WaitForChild——如果子级始终未到达,它会永久让出执行权。:WaitForChild("Name", 5) 会在 5 秒后返回 nil。

脚本类型和位置

三种脚本类型:

  • Script — 根据 Script.RunContext 和所在位置,在服务器或客户端运行。
    • RunContext = Legacy(默认)→ 仅在服务器运行,只在服务器容器中运行(ServerScriptService、Workspace 等)。
    • RunContext = Server → 在服务器运行,也可从 ReplicatedStorage 运行(但不要这样做——客户端可以读取源代码)。
    • RunContext = Client → 在客户端运行,从 ReplicatedStorage 运行。建议将其用于新的客户端脚本(比 LocalScript 更清晰)。
  • LocalScript — 仅在客户端运行。没有 RunContext。只在客户端容器中运行:StarterPlayerScripts、StarterCharacterScripts、StarterGui、StarterPack、ReplicatedFirst。
  • ModuleScript — 可由任一端通过 require() 调用。它在调用者的上下文中运行。
推荐布局
位置内容RunContext
ServerScriptService服务器脚本 + 仅限服务器的 ModuleScriptServer
ServerStorage仅限服务器的数据(资源、表)——客户端看不到其中的内容不适用
ReplicatedStorage共享的 ModuleScript(服务器 + 客户端)以及客户端脚本客户端脚本使用 Client
ReplicatedFirst最精简的客户端加载器(例如加载屏幕)Client
StarterPlayerScripts / StarterCharacterScripts / StarterGui / StarterPack按玩家运行的 LocalScript不适用
Workspace世界内容。用于对象行为的服务器端 Script。如需明确指定,则使用 Server

常见做法:将大部分代码作为 ModuleScript 放在 ReplicatedStorage(共享)或 ServerStorage/仅限服务器的位置,并在 ServerScriptService 中设置一个服务器入口 Script 来调用这些模块,再在 ReplicatedStorage 中设置一个客户端入口(RunContext = Client)。

规则
  • **不要将服务器逻辑放在 ReplicatedStorage 中。**客户端可以读取源代码;请假设其中的任何内容都会泄露。
  • 明确设置模型或包内脚本的 RunContext——当模型重新设置父级或共享时,这样可以消除歧义。
  • 在共享的 ModuleScript 中使用 RunService:IsServer() / :IsClient() 进行运行时分支。不要根据 script.Parent 推断。
  • **Workspace 中的 LocalScript 不会运行。**只有客户端可运行的容器才行。这是常见错误。
  • Workspace 中的服务器脚本会运行,但将 RunContext = Server 设置在 ServerScriptService 中更清晰,并且在几何对象移动时更不容易令人意外。

来源:create.roblox.com/docs/scripting/locations。


复制

  • 服务端 → 客户端会自动复制到已复制的容器中(Workspace、ReplicatedStorage、Players[X].PlayerGui、克隆到 PlayerGui 的 StarterGui)。属性更改和父级更改都会复制。
  • **客户端 → 服务端不会复制。**客户端创建或修改的任何内容都只保留在本地,服务端永远看不到。例外包括:Players[X].Character(过滤时代及角色所有权)、输入事件和显式的 RemoteEvent 调用。
  • 复制是按属性进行的,并非全有或全无。有些容器两端都存在,但其中的内容仅在客户端本地(Player.PlayerScripts、StarterGui 初始复制后的 Player.PlayerGui)。
  • **先设置属性,再设置父级。**实例进入已复制的容器后,之后的每次属性更改都会作为独立更新进行复制,客户端可能会观察到中间状态。先完整构建实例,再设置 Parent。
  • 信号行为很重要。Workspace.SignalBehavior = Enum.SignalBehavior.Deferred(新场所中的现代默认值)会将信号触发批处理到下一个恢复点;假设 ChildAdded 会同步触发的代码很脆弱。请在启用延迟信号的情况下进行测试。
  • 完整表格请参阅 references/replication-model.md。

RemoteEvents 和 RemoteFunctions

-- Server
local TakeDamage = ReplicatedStorage:WaitForChild("TakeDamage") :: RemoteEvent
TakeDamage.OnServerEvent:Connect(function(player, amount: unknown)
    if typeof(amount) ~= "number" or amount < 0 or amount > 100 then return end
    -- validated; apply
end)

-- Client
TakeDamage:FireServer(25)
  • **OnServerEvent 的第一个参数始终是触发事件的 Player。**它是真实可信的,由 Roblox 提供。其他任何内容都不要信任。
  • 将其他所有参数都设为 unknown,并在边界处进行验证。设为 number 会让类型检查器接受利用者输入。(完整模式请参阅 luau-expert 技能。)
  • RemoteFunction 使用 :InvokeServer() / :InvokeClient(),调用是同步的(会让出执行权)。尽可能避免客户端到服务端的 InvokeClient;恶意客户端可以让调用一直挂起。优先使用 RemoteEvent 加回复事件。
  • **对每个 Remote 都进行速率限制。**跟踪每位玩家的调用次数;拒绝明显的垃圾请求。默认做法:使用有界队列,队列满时丢弃最旧的请求或直接拒绝。
  • **每种逻辑操作使用一个 Remote。**不要通过 kind 字段进行多路复用,这样类型更清晰,验证也更简单。

角色生命周期

local function onCharacterAdded(character: Model)
    local humanoid = character:WaitForChild("Humanoid") :: Humanoid
    -- ...
end

Players.PlayerAdded:Connect(function(player)
    if player.Character then onCharacterAdded(player.Character) end
    player.CharacterAdded:Connect(onCharacterAdded)
end)
  • **触发 PlayerAdded 时,玩家的第一个角色可能已经存在。**检查 player.Character,并连接到后续的 CharacterAdded。
  • **每次重生都会生成一个新的 Model。**指向旧角色的引用会变得过时。在 CharacterAdded 中重新连接每个角色的信号;使用 character.AncestryChanged 或 humanoid.Died 清理上一组连接。
  • 每个角色的 Humanoid.Died 只会触发一次。死亡后,模型会继续存在一段时间(默认约 5 秒,由 Players.RespawnTime 控制),之后自动重生会将其销毁。
  • Player.CharacterRemoving 会在模型离开 Workspace 之前触发,这是读取其状态的最后机会。

流式加载

现代场所会启用 Workspace.StreamingEnabled。世界中的部分区域会在每位玩家周围加载和卸载。

  • 客户端可能看不到服务端可见的部件。切勿假设客户端上的几何体一定存在,应使用带超时的 WaitForChild,或检查是否为 nil。
  • 每个 Model 的流式加载由 Model.ModelStreamingMode 控制:
    • Default —— 模型按常规方式进行流式加载,后代会独立加载。
    • Atomic —— 整个模型作为一个整体加载或卸载(不会部分加载)。
    • Persistent —— 所有客户端始终都会加载该模型。
    • PersistentPerPlayer —— 通过相关 Add/Remove API 添加的玩家始终会加载该模型;其他玩家默认不会加载。
  • 对于后代必须同时到达的对象(车辆组件、NPC 骨架),请使用 Atomic。谨慎使用 Persistent,因为它会消耗每个客户端的内存。
  • 即使部件已卸载,属性更改仍会复制;只是客户端不会渲染它们。不要更新已卸载对象的 BasePart.CFrame,这会浪费带宽。
  • 关于区块语义以及 ModelStreamingMode 的交互,请参阅 references/streaming-enabled.md。

物理所有权

  • 每个未锚定的 BasePart 都有一个网络所有者。默认情况下,所有者是服务器。当玩家的角色触碰、踩到或持有一个部件时,所有权会转移到该玩家的客户端。
  • 所有者负责模拟物理效果;模拟结果会同步回来。客户端拥有的部件,其位置并非由服务器权威决定——切勿将其 CFrame 用于游戏状态判断。
  • 在服务器端强制设置所有权:part:SetNetworkOwner(player)——传入 nil 可将所有权交还给服务器。:GetNetworkOwner() 可读取所有者。
  • 锚定部件没有所有者。 对锚定部件调用 SetNetworkOwner 会报错。如果不控制输入,请先使用 BasePart:CanSetNetworkOwnership() 检查。
  • 默认启用自动转移。 对部件调用 :SetNetworkOwner(player) 或 :SetNetworkOwner(nil) 后,该部件的自动转移会关闭——所有权会保持在你设置的位置。调用 :SetNetworkOwnershipAuto() 可重新启用自动转移。
  • 对于玩家不得传送的任何部件(宝箱、NPC、投射物、反作弊相关部件),只需调用一次 SetNetworkOwner(nil),切勿调用 SetNetworkOwnershipAuto()。

云服务(持久化与跨服务器)

Roblox 提供多种存储选项。应根据访问模式选择,而不是凭直觉。

服务持久性范围用途
标准数据存储(DataStoreService:GetDataStore)永久跨服务器用户进度、物品栏、存档数据。支持数字、字符串、布尔值和表。类似 NoSQL。
有序数据存储(:GetOrderedDataStore)永久跨服务器全时排行榜(仅支持数字,可排序)。
内存存储(MemoryStoreService)≤ 45 天跨服务器匹配、每日/每月排行榜、临时跨服务器队列。速度比数据存储快。
配置(Creator Hub)永久跨服务器功能开关、可调参数。在游戏内只能读取。
密钥存储永久跨服务器API 密钥、第三方令牌。在游戏内只能读取。
内存中的 Luau 表会话期间单个服务器临时状态(计时器、状态效果、当前生命值)。免费且即时。

云服务仅限服务器使用。 客户端无法读取 DataStore、MemoryStore 等服务。客户端需要访问时,需通过 RemoteEvent 转发。

数据存储核心规则
  • 始终使用 pcall。 网络故障、请求节流和配额耗尽都可能导致读写抛出错误。
  • 并发状态使用 UpdateAsync(而非 SetAsync)——它会提供之前的值并以原子方式合并数据。在转换函数中返回 nil 可中止操作,且不会消耗配额。
  • 会话锁定模式:在 PlayerAdded 时读取一次,在会话期间修改内存中的数据,并在 PlayerRemoving 和 BindToClose 时保存。不要在每次数据变更时都读取。
  • game:BindToClose 在所有玩家间共享约 30 秒的总时限。使用 task.spawn 并行保存。
  • 速率限制按键和游戏分别计算,并会随时间补充。此外还有一条硬性规则:每个键约 6 秒一次。
内存存储核心规则
  • 临时性:数据最多在 45 天后过期。不要将存档数据放在这里。
  • 可用的基本结构:队列、有序映射、哈希映射。写入吞吐量高于数据存储。
  • API 仅限服务器使用。

完整的选型指南(包括何时使用哪种服务)请参阅 references/cloud-services.md;数据存储速率限制、重试/退避和会话锁定模式请参阅 references/datastore-rules.md。

来源:create.roblox.com/docs/cloud-services/data-stores-vs-memory-stores。


任务调度

task 库取代了经典的 wait/spawn/delay。请使用它。

调用时机
task.spawn(f, ...)立即在新线程上运行 f(在当前恢复点之后恢复)。用于将会让出执行权的主体从事件处理程序中分离出来。
task.defer(f, ...)在当前恢复周期结束后运行 f——比 spawn 更晚。用于合并工作,且不会让出执行权。
task.delay(t, f, ...)在新线程上于 t 秒后运行 f。取代旧版 delay。
task.wait(t)让出当前线程 t 秒(若省略,则等待一帧)。取代 wait。
task.cancel(thread)终止由 task.spawn/task.delay 启动的线程。请配合显式清理使用。
task.synchronize() / task.desynchronize()用于 Parallel Luau Actor 协调。请参阅下方的 Parallel Luau 部分。
  • 不要使用旧版全局函数 wait、spawn、delay。它们会限制执行频率(wait 会限制到约 30 Hz),而且在一些使用方式下会造成泄漏,task 版本则不会。
  • 在事件处理程序中使用 task.spawn 可以让快速触发的事件并发运行。如果主体会修改共享状态,请自行添加队列或锁。
  • 需要在当前事件处理流程结束后执行工作时,使用 task.defer 而非 task.spawn——适用于会修改世界并触发更多信号的 :Connect 主体。
  • 如果线程需要取消,请跟踪由 task.spawn/task.delay 启动的线程;Instance 销毁时不会自动取消线程。

Buffer 库(二进制数据)

buffer 是 Luau 的定长可变字节块。来源:buffer.yaml in creator-docs。

适用于:

  • 紧凑的 RemoteEvent 载荷(通过网络传输时,打包后的 buffer 比 Luau 表更小)。
  • 为需要保持较小体积的 DataStore 值自定义二进制序列化。
  • 读取/写入现有二进制格式。
  • 替代 string.pack / string.unpack 的使用场景。
local b = buffer.create(16)        -- 16-byte buffer, zero-initialized
buffer.writeu32(b, 0, 0xCAFEBABE)  -- 4 bytes at offset 0
buffer.writef32(b, 4, 1.5)         -- 4 bytes at offset 4
buffer.writestring(b, 8, "abc")    -- 3 bytes at offset 8

local magic = buffer.readu32(b, 0)
local payload = buffer.readstring(b, 8, 3)

主要规则:

  • 所有偏移量都是从 0 开始的字节偏移量。 超出 buffer 范围读取或写入会引发错误。
  • 所有数值读写都采用小端序。
  • buffer 最大大小:1 GiB(buffer.create 会拒绝更大的大小;即使分配接近此上限也可能失败)。
  • 通过 Roblox API(RemoteEvents 等)发送时会进行复制。 接收方不会保留对象标识。不要在 Actor 脚本之间共享 buffer(Parallel Luau)。
  • 大小固定。 无法扩容或缩容——请分配合适的大小,或复制到新的 buffer 中(buffer.copy)。

函数分为三组:

  • 生命周期: buffer.create(size)、buffer.fromstring(s)、buffer.tostring(b)、buffer.len(b)。
  • 定宽数值的读写: readi8/u8/i16/u16/i32/u32/f32/f64 及对应的 write*。
  • 位级操作和批量操作: readbits(b, bitOffset, bitCount) / writebits(每次调用 0–32 位)、readstring(b, offset, count) / writestring(b, offset, value, count?)、buffer.copy(target, targetOffset, source, sourceOffset?, count?)、buffer.fill(b, offset, value, count?)。

完整的函数签名参考见 references/buffer-library.md。


Parallel Luau(多线程)

默认情况下,Luau 是单线程的。Parallel Luau 通过 Actor 模型让互不依赖的工作在多个 OS 线程上运行。适用于可拆分的 CPU 密集型工作:逐玩家射线检测验证、程序化生成、大量逐实例计算。

Actor 模型
  • Actor Instance 下的脚本可以并行运行。
  • 每个 Actor 对应一个线程。 同一个 Actor 中的多个脚本仍会彼此串行运行。
  • Actor 越多,负载均衡效果越好,即使设备的核心数少于 Actor 数量也是如此。
  • 不要将 Actor 嵌套为其他 Actor 的后代——脚本所属的 Actor 是其最近的祖先。
阶段:串行与并行

代码默认以串行方式运行。切换方式:

-- Inside an Actor's script
RunService.Heartbeat:ConnectParallel(function()
    -- parallel phase
    local result = computeExpensive()
    task.synchronize()
    -- serial phase
    applyResult(result)
end)
  • task.desynchronize() — 将当前线程切换为并行。让出执行权,并在下一个并行执行机会到来时恢复。
  • task.synchronize() — 切回串行。在修改大多数 Instance 之前必须调用。
  • Signal:ConnectParallel(callback) — 自动安排信号回调以并行方式运行;无需在其中调用 desynchronize。
  • 并行阶段不允许使用 require()。 请先在串行阶段加载模块。
线程安全级别

API 成员带有线程安全标签:

级别属性函数
Unsafe不允许并行读写不允许并行调用
Read Parallel可读取,不可写入不适用
Local Safe同一 Actor 内可执行任何操作;跨 Actor 只读仅限同一 Actor
Safe可读写可从任意位置调用

未标注时,默认为 Unsafe。大多数以读取为主的访问(几何查询、射线检测)都是安全的;大多数修改操作则不安全。请查看 API 参考中各成员的说明。

跨线程通信
  • Actor:SendMessage(topic, ...) + Actor:BindToMessage(topic, fn) / :BindToMessageParallel(topic, fn) — 异步单向消息传递。每个主题可以有多个绑定。
  • SharedTable — 可在各 Actor 间共享、支持原子更新的类表结构。发送时不会复制。适用于“未存储在 DataModel 中的公共世界状态”。
  • 在串行阶段直接写入 DataModel也可行,但会导致频繁同步。
不适用的场景
  • 长时间不间断的计算。 即使在线程中并行运行,不让出执行权的 100 ms 循环仍会阻塞该线程 100 ms。请拆分工作。
  • 每帧开销很小的工作。 对于琐碎任务,Actor 的开销远大于节省的成本。
  • 主要修改 DataModel 的代码。 大部分时间都会花在 task.synchronize 上,从而失去并行的意义。

完整深入介绍:references/parallel-luau.md。来源:create.roblox.com/docs/scripting/multithreading。


性能

性能优化是一个循环:提前设计、使用 MicroProfiler 识别热点、进行优化、持续监控。以下是最值得关注的部分;完整的陷阱列表见 references/performance-optimization.md。

帧预算
  • 目标:客户端达到 60 FPS = 每帧 16.67 ms。服务器没有帧率,但 RunService.Heartbeat 扮演相同角色。
  • Luau 侧任何持续每帧运行超过 1 ms 的操作都值得考虑优化。
常见错误
  • 在 RunService.Heartbeat 上执行大量工作(或 PreSimulation/PostSimulation/PreRender/PreAnimation)。Heartbeat 每帧都会触发,应按间隔限制执行频率(if now - last < 0.1 then return end),或将工作移到频率较低的循环中。
  • 在服务端使用 TweenService。 补间属性会每帧复制,遇到延迟时会卡顿。除非结果必须由服务端权威控制,否则应在客户端执行补间。
  • 连接到 Player / 角色后从不断开。 玩家离开时,Player 实例不会自动销毁(参见 Workspace.PlayerCharacterDestroyBehavior,可选择启用自动销毁)。应跟踪并显式断开连接,或在 PlayerRemoving 中手动销毁。
  • 表无限增长。 玩家加入时设置 playerInfo[player] = ...,但离开时不执行 playerInfo[player] = nil,是典型的服务端内存泄漏。
  • 深度克隆大型表。 使用浅拷贝;仅修改发生变化的路径(参见 luau-expert 技能中的交叉链接)。
  • RemoteEvent 负载过大。 发送增量,而不是整个物品栏。对于高频状态,可打包到 buffer 中。
  • 过度创建和销毁实例。 在运行时克隆大型角色模型会将整个后代树复制给客户端。预先创建实例池优于每次事件都创建实例。
  • 不会移动的未锚定部件。 设置 Anchored = true 可将其完全移出物理模拟。
  • CollisionFidelity 过高。 Precise 会消耗大量 CPU 和内存。对于玩家察觉不到差异的对象,应使用 Box/Hull。
  • 资源重复。 同一网格/纹理多次上传 = 同一内容在内存中重复存储。应重复使用资源 ID。
  • 已发布角色模型中的动画元数据。 克隆角色模型上的 Animation Editor 数据会随每个克隆一起复制。发布前应将其移除。
  • 为了实现“无弹入”加载画面而对整个 Workspace 使用 PreloadAsync。 只预加载加载画面和起始区域所需的内容。
  • 对高频且不让出执行权的计算路径使用原生代码生成(--!native),这是轻松提升性能的办法——但应先确认该函数确实是性能热点。
值得了解的 MicroProfiler 范围
  • RunService.Heartbeat / PreRender / PreSimulation / PostSimulation — 你的脚本。
  • physicsStepped / worldStep — 物理开销。
  • ProcessPackets / Allocate Bandwidth — 网络开销。
  • updateInvalidatedFastClusters — Humanoid/蒙皮网格的频繁变更。

使用 debug.profilebegin("Name") / debug.profileend() 为你自己的范围添加标记。

来源:create.roblox.com/docs/performance-optimization、/improve。


Bug 目录

重复连接
-- ❌ every CharacterAdded adds another connection; old ones never disconnect
Players.PlayerAdded:Connect(function(player)
    player.CharacterAdded:Connect(function(char)
        char.Humanoid.Died:Connect(function() ... end)   -- leaks per respawn
    end)
end)

按作用域跟踪并断开连接。一种常见模式是:用生命周期所有者(玩家、角色、实例)作为键,存储 { RBXScriptConnection };清理时遍历并调用 :Disconnect()。

过期引用
local character = player.Character
task.wait(5)
character.Humanoid.Health = 0     -- ❌ player may have respawned; old model is destroyed

每次让出执行权后重新获取 player.Character,或者捕获并验证:if character.Parent == nil then return end。

在事件处理程序中让出执行权
RemoteEvent.OnServerEvent:Connect(function(player)
    task.wait(1)              -- ❌ all subsequent fires from this player wait behind this one
    grantReward(player)
end)

要么不要在处理程序中让出执行权,要么使用 task.spawn 执行主体,让每次触发都独立运行。使用 task.spawn 时要小心——并发状态更新需要加锁。

内存泄漏
  • Instance:Destroy() 会断开事件并将实例从 DataModel 中移除,但不会解除 Lua 侧的引用。如果变量的生命周期较长,应将其设为 nil。
  • 连接到非 Roblox 信号(自定义信号、库)时不会自动清理。应跟踪并断开这些连接。
  • :Connect(...) 中捕获大型表的闭包会让这些表一直存活,直到连接断开。
CollectionService 清理
-- ❌ tag handler runs once per existing tagged instance, then once per new one
CollectionService:GetTagged("Door"):forEach(setupDoor)
CollectionService:GetInstanceAddedSignal("Door"):Connect(setupDoor)

始终配合 GetInstanceRemovedSignal 进行清理。使用 { [Instance]: State } 映射跟踪每个实例的状态,不要将状态保存在闭包中。使用 instance.AncestryChanged(当 parent == nil 时)或移除信号。

设置过程中有玩家加入的竞态

脚本启动时,PlayerAdded 会为服务器中已有的玩家触发(通过 :Connect 绑定只能捕获之后触发的事件)——请手动处理现有玩家:

for _, player in Players:GetPlayers() do onPlayerAdded(player) end
Players.PlayerAdded:Connect(onPlayerAdded)

架构规则

  • 验证每个服务器边界。 RemoteEvent 参数、DataStore 读取结果、MessagingService 负载、HttpService 响应,以及任何受用户影响的内容。
  • 服务器权威检查优于客户端检查。 客户端验证是为了改善用户体验(即时反馈);服务器验证是为了保障安全。两者缺一不可。
  • 一个 ModuleScript 只负责一项职责。 不要将功能汇总到庞大的“Manager”模块中——应组合职责明确、所有权清晰的小模块。(模块和文件结构规则请参阅 luau-expert。)
  • 每项状态只由一个所有者负责。 一个字段要么由服务器所有,要么仅用于复制,要么由客户端所有。绝不能有多个写入方。如果多个参与方必须更新某项内容,就通过一个负责串行处理写入的模块来路由。
  • 集中管理复制。 每个复制域(物品栏、货币、任务进度)使用一个模块,而不是将 Remotes 分散在代码库各处。这样更易审查,也更容易添加验证和速率限制。
  • 不要信任客户端提供的 UserId。 从任何真实的 Player 实例中读取的 UserId(OnServerEvent 的第一个参数、Players:GetPlayers()、Players:GetPlayerByUserId)都是真实可信的。客户端作为 RemoteEvent 参数发送的 UserId 只是一个数字——应进行验证或忽略。
  • 服务器处理程序应具备幂等性。 网络卡顿导致的重放或利用程序造成的重放确实会发生。设计 RemoteEvent 处理程序时,应确保两次相同的触发产生相同的最终状态。
  • 耗费资源的工作放在服务器端。 路径规划、大型查询以及任何 CPU 密集型任务都应在服务器端执行。客户端设备的性能差异很大,而服务器性能一致。
  • 不要只依赖 MessagingService 构建跨服务器状态。 它采用尽力交付机制,不保证送达,并且每个游戏都有速率限制。应将其用于发送“重要事件”通知,而不是同步状态。
  • 脚本容器应与运行环境匹配。 服务器逻辑放在 ServerScriptService/ServerStorage;客户端逻辑放在 StarterPlayerScripts/StarterCharacterScripts/StarterGui;共享模块放在 ReplicatedStorage。不要将仅限服务器的逻辑放在 ReplicatedStorage 中——客户端可以读取其中的内容。

验证规则

  • 不要凭记忆引用 API 签名,除非涉及最稳定的接口(Instance.Parent、BasePart.CFrame、Humanoid.Health 等)。Roblox 会重命名、弃用并新增 API。如有疑问,请引导用户查看 create.roblox.com/docs,并说明你不确定。
  • 行为原则优先于 API。 应将规则表述为“服务器对……具有权威性”,而不是“使用 Workspace.StreamingMinRadius = 256”——属性可能会重命名,原则却不会。
  • 区分稳定内容与变化内容。 DataModel 层级结构、服务、复制方向、角色生命周期:多年来都很稳定。新 API(例如近期新增的 Workspace 属性、新的 RunService 方法、与脚本相关的测试版功能):推荐前应先核实。
  • 不要混淆引擎与语言。 如果问题涉及类型或 --!strict 的行为,请转交给 luau-expert。如果涉及 Rojo 或文件系统布局,请转交给 roblox-toolchain。如果涉及 UI 库,请转交给 roblox-ui。

反模式

❌ 不要✅ 应该
game.Workspace / workspace 全局变量game:GetService("Workspace")
只使用 game.Players.PlayerAdded:Connect(f)先遍历 :GetPlayers(),再使用 :Connect 处理启动期间加入的玩家
在客户端使用 child = parent:FindFirstChild("X")使用 :WaitForChild("X", timeout)
仅通过类型注解就信任客户端传来的 RemoteEvent 参数将类型标注为 unknown,进行验证并缩窄类型
RemoteFunction:InvokeClient使用 RemoteEvent 往返通信
使用一个 Remote,通过 kind 字段复用多个操作每个操作使用一个 Remote
使用 SetAsync 保存并发状态使用 UpdateAsync
调用 DataStore 时不使用 pcall始终使用 pcall,并进行重试和退避
每次状态变化时都读取 DataStore使用会话缓存;在 PlayerRemoving 和 BindToClose 时写入
在让出执行后继续使用角色引用每次让出执行后重新获取 player.Character
重生时连接事件但不断开连接跟踪每个角色的连接,并在角色移除时断开
使用 Instance.Parent = nil 清理使用 :Destroy()
使用 WaitForChild 时不设置超时使用 :WaitForChild("X", 5) 并处理 nil
只在客户端进行验证始终在服务端验证
在频繁触发的事件处理程序中让出执行使用 task.spawn 运行处理逻辑;通过自建队列进行串行处理
对敏感部件使用自动网络所有权显式调用 SetNetworkOwner(nil)
遍历标签集合时不配对移除信号将 GetInstanceAddedSignal 与 GetInstanceRemovedSignal 配对使用
将服务端逻辑放在 ReplicatedStorage 中使用 ServerScriptService / ServerStorage
将 LocalScript 放在 Workspace 中使用客户端容器(StarterPlayerScripts 等),或在 ReplicatedStorage 中使用 RunContext = Client 的 Script
使用旧版全局函数 wait / spawn / delay使用 task.wait / task.spawn / task.defer / task.delay
直接在 RunService.Heartbeat 上执行繁重工作降低执行频率/移至运行频率较低的循环/使用 task.delay
在服务端使用 TweenService 制作视觉动画在客户端执行补间动画;仅同步目标
每次事件发生时都在运行时克隆大型装配体使用对象池/预先实例化
对小型部件设置 CollisionFidelity = Precise使用 Box / Hull
在并行阶段修改 Instances先调用 task.synchronize()
在 DataStores 中存储 API 密钥使用机密存储
在 MemoryStore 中保存数据使用标准数据存储

示例

评论

支持 Markdown