开始之前
理解服务器、Attributes、ProximityPrompt 和 pcall。建议分两次学习,先完成练习模式。
Roblox Studio · 第 25 课
先用离线练习模式理解读取与保存,再学习用 pcall 和 UpdateAsync 保护最高纪录。
Course overview
前面的金币和任务只活在当前服务器里。DataStore 可以让数据在离开后仍然存在,但网络请求会失败,因此“点了保存”并不等于“已经保存成功”。
这一课分两轮。第一轮默认 PRACTICE=true,只使用当前服务器的内存练习;第二轮由你和家长或老师准备独立测试体验后,才可切换到真实 DataStore。练习模式不会跨 Stop 保存。
理解服务器、Attributes、ProximityPrompt 和 pcall。建议分两次学习,先完成练习模式。
按一次得分站增加 10 分,按保存站保存最高纪录;Output 明确显示练习或云端结果。
最高纪录不会降低;读取失败时禁止覆盖;失败不能输出“保存成功”。

Learning route
每完成一小步,就测试一次。先说出你预计会看到什么,再用 Play 和 Output 检查,不用急着一次写完所有代码。
在新 Baseplate 中放两个小型 Part:ScoreStation 与 SaveStation,分别加入 ProximityPrompt。一个给本轮分数,一个尝试保存。
每名玩家用 UserId 组成数据键,比玩家昵称稳定。保存时用 UpdateAsync 读取当前值并返回更大的纪录,避免旧服务器把较高分数写低。
代码稍长,可以先读 setup,再读两个 Prompt 的回调。不要一口气背下来;先找到“加载成功才能保存”和“最高纪录取较大值”这两条规则。
真实 DataStore 只能由服务器访问。请与家长或老师使用一个独立的测试体验,按当前 Studio 的设置启用所需 API 访问;不要把练习连接到正式游戏的数据。
练习模式 Stop 后重置是预期行为,不能拿它作为跨会话保存成功的证明。云端模式要在重新进入后读到相同或更高纪录,才完成这项验证。
Step by step
在新 Baseplate 中放两个小型 Part:ScoreStation 与 SaveStation,分别加入 ProximityPrompt。一个给本轮分数,一个尝试保存。
这不是完整线上玩家数据系统。它只演示手动保存“只升不降的最高纪录”,没有退出自动保存、货币交易或复杂会话锁。
Step by step
每名玩家用 UserId 组成数据键,比玩家昵称稳定。保存时用 UpdateAsync 读取当前值并返回更大的纪录,避免旧服务器把较高分数写低。
UpdateAsync 的回调可能重试,所以里面只计算返回值,不播放音效、不发奖励,也不能 task.wait。读取失败时不把 0 当作真实旧数据继续覆盖。
Step by step
代码稍长,可以先读 setup,再读两个 Prompt 的回调。不要一口气背下来;先找到“加载成功才能保存”和“最高纪录取较大值”这两条规则。
两个站台都有服务器距离检查和冷却。练习模式与云端模式走同样的流程,只是底层存储不同,Output 前缀会明确区分。
local Players = game:GetService("Players")
local PRACTICE = true -- 先保持 true;内存练习不跨 Stop 保存
local store = nil
if not PRACTICE then
store = game:GetService("DataStoreService"):GetDataStore("Lesson25_Best_v1")
end
local memory = {}
local ready, saving, lastSave, lastScore = {}, {}, {}, {}
local scoreStation = workspace:WaitForChild("ScoreStation")
local saveStation = workspace:WaitForChild("SaveStation")
local mode = PRACTICE and "[PRACTICE]" or "[DATASTORE]"
local function readValue(value)
if value == nil then return 0 end
assert(typeof(value) == "number" and value == value, "Invalid stored data")
assert(value >= 0 and value < math.huge and value % 1 == 0, "Invalid score")
return value
end
local function near(player, station)
local character = player.Character
local root = character and character:FindFirstChild("HumanoidRootPart")
local hum = character and character:FindFirstChildOfClass("Humanoid")
return root and hum and hum.Health > 0 and (root.Position - station.Position).Magnitude <= 12
end
local function setup(player)
player:SetAttribute("RunScore", 0)
local key = "u_" .. player.UserId
local ok, value = pcall(function()
if PRACTICE then return readValue(memory[key]) end
return readValue(store:GetAsync(key))
end)
if player.Parent ~= Players then return end
if not ok then warn(mode, "Load failed; saving disabled", value); return end
ready[player] = true
player:SetAttribute("BestScore", value)
print(mode, player.Name, "loaded", value)
end
Players.PlayerAdded:Connect(setup)
for _, player in ipairs(Players:GetPlayers()) do task.spawn(setup, player) end
scoreStation.ProximityPrompt.Triggered:Connect(function(player)
if not ready[player] or not near(player, scoreStation) then return end
local now = os.clock()
if now - (lastScore[player] or -math.huge) < 0.5 then return end
lastScore[player] = now
local score = math.min(9990, (player:GetAttribute("RunScore") or 0) + 10)
player:SetAttribute("RunScore", score)
print(mode, "RunScore", score)
end)
saveStation.ProximityPrompt.Triggered:Connect(function(player)
if not ready[player] or saving[player] or not near(player, saveStation) then return end
local now = os.clock()
if now - (lastSave[player] or -math.huge) < 10 then return end
lastSave[player] = now
saving[player] = true
local key = "u_" .. player.UserId
local score = player:GetAttribute("RunScore") or 0
local ok, result = pcall(function()
if PRACTICE then
memory[key] = math.max(readValue(memory[key]), score)
return memory[key]
end
return store:UpdateAsync(key, function(old)
return math.max(readValue(old), score)
end)
end)
saving[player] = nil
if not ok then warn(mode, "Save failed; try again later", result); return end
if player.Parent == Players then player:SetAttribute("BestScore", result) end
print(mode, "Saved best:", result)
end)
Players.PlayerRemoving:Connect(function(player)
ready[player], lastSave[player], lastScore[player] = nil, nil, nil
end)
Step by step
真实 DataStore 只能由服务器访问。请与家长或老师使用一个独立的测试体验,按当前 Studio 的设置启用所需 API 访问;不要把练习连接到正式游戏的数据。
这是权限与云端数据操作,不是这节课必须完成的门槛。没有合适的测试环境,就保留 PRACTICE=true 完成流程学习,不要使用他人的账号或绕过限制。
Step by step
练习模式 Stop 后重置是预期行为,不能拿它作为跨会话保存成功的证明。云端模式要在重新进入后读到相同或更高纪录,才完成这项验证。
这份最高值合并适合纪录,不适合可增可减的金币。把所有货币写成 math.max 会让花掉的钱回来;不要把一个例子用到不相同的数据规则。

Homework
画出加载成功、加载失败、保存成功、保存失败四条路线。
写明哪一种不跨 Stop 保存,哪一种必须有独立云端测试环境。
用 Attribute 显示“正在保存/已保存/保存失败”,但仍由服务器决定状态。
Exit questions
它适合稳定地对应玩家,昵称可能改变。
读取失败不代表旧数据不存在,写 0 可能覆盖真实纪录。
不应该,它可能重试,应只计算并返回新值。
不能,余额会减少,需要不同的数据设计。
Explore further
正文代码可以直接复制。先确认脚本类型、放置位置,以及这段代码是完整版本还是小实验;不要把同一脚本的多个版本叠加运行。界面参考图已标注来源,图中的对象名可能与本课不同,请以操作步骤为准。