开始之前
掌握第 10、13、15–19 课,能读懂服务器与客户端分工。建议分两次完成。
Course overview
你的目标很清楚:从营地出发,找到三颗不同晶石,再回到终端完成任务。屏幕会告诉你进度,终端会检查你是否真的集齐。
这节课从全新的 Baseplate 开始,使用下面列出的对象和两份完整脚本。前面的知识会帮助你读懂它,但不需要把二十多课的旧脚本全部搬进来。
掌握第 10、13、15–19 课,能读懂服务器与客户端分工。建议分两次完成。
可双人独立游玩的寻宝游戏:收集、进度、终点校验与完成提示完整连接。
没集齐不能完成;重复晶石不计数;两位玩家进度独立;完成奖励只发一次。

Learning route
每完成一小步,就测试一次。先说出你预计会看到什么,再用 Play 和 Output 检查,不用急着一次写完所有代码。
第一版只做三颗晶石和一个终端。没有倒计时、商店和云端存档,避免多个系统一起出错。先走完一轮,再选择一项扩展。
严格一致的对象名字,是把场景和代码接起来的第一步。你可以改变颜色和装饰,但先不要改变脚本使用的名称与层级。
IslandCount 是晶石数,IslandFinished 表示是否已完成,IslandMessage 给界面提供提示。collected 表记录具体领过哪些晶石,防止重复触碰刷进度。
HUD 只读取服务器状态,不发奖也不判定任务完成。初始化时先监听变化,再 render 一次,以免界面打开时已经错过早先的状态。
“通关了一次”还不足以证明任务可靠。你也要尝试没集齐、重复领取和重复提交,确认规则在这些情况下仍成立。
先保存 v01,再挑一项扩展:更清楚的路标、完成粒子或温和夜景。不要同时加入计时、存档和商店。
Step by step
第一版只做三颗晶石和一个终端。没有倒计时、商店和云端存档,避免多个系统一起出错。先走完一轮,再选择一项扩展。
本次会话中,角色重生保留已收集进度;离开后重置。完成时得到一枚本次游戏奖章,重复使用终端不会继续增加。
Step by step
严格一致的对象名字,是把场景和代码接起来的第一步。你可以改变颜色和装饰,但先不要改变脚本使用的名称与层级。
把晶石排在能看见的地方,测试成功后再藏到路线边上。过早隐藏目标会让你分不清是玩法太难还是代码没响应。
Workspace
├─ Crystals (Folder)
│ ├─ CrystalA (Part)
│ ├─ CrystalB (Part)
│ └─ CrystalC (Part)
└─ Terminal (Part)
└─ ProximityPrompt
ServerScriptService
└─ IslandQuest (Script)
StarterPlayer > StarterPlayerScripts
└─ IslandHud (LocalScript)
Step by step
IslandCount 是晶石数,IslandFinished 表示是否已完成,IslandMessage 给界面提供提示。collected 表记录具体领过哪些晶石,防止重复触碰刷进度。
终端只读服务器自己的 count,不接收客户端上报的“我集齐了”。状态检查、距离检查和一次性标记都通过后,才增加奖章。
local Players = game:GetService("Players")
local crystals = workspace:WaitForChild("Crystals")
local terminal = workspace:WaitForChild("Terminal")
local prompt = terminal:WaitForChild("ProximityPrompt")
local TARGET = 3
local collected, lastUse = {}, {}
local function setup(player)
if collected[player] then return end
collected[player] = {}
player:SetAttribute("IslandCount", 0)
player:SetAttribute("IslandFinished", false)
player:SetAttribute("IslandMessage", "找到 3 颗不同晶石,再前往能量终端。")
local stats = Instance.new("Folder")
stats.Name = "leaderstats"
local medals = Instance.new("IntValue")
medals.Name = "Medals"
medals.Value = 0
medals.Parent = stats
stats.Parent = player
end
local function near(player, part, distance)
local character = player.Character
local root = character and character:FindFirstChild("HumanoidRootPart")
local humanoid = character and character:FindFirstChildOfClass("Humanoid")
return root and humanoid and humanoid.Health > 0
and (root.Position - part.Position).Magnitude <= distance
end
Players.PlayerAdded:Connect(setup)
for _, player in ipairs(Players:GetPlayers()) do setup(player) end
Players.PlayerRemoving:Connect(function(player)
collected[player], lastUse[player] = nil, nil
end)
for _, crystal in ipairs(crystals:GetChildren()) do
if not crystal:IsA("BasePart") then continue end
crystal.Touched:Connect(function(hit)
local character = hit:FindFirstAncestorOfClass("Model")
local player = character and Players:GetPlayerFromCharacter(character)
if not player or not near(player, crystal, 8) then return end
local record = collected[player]
if not record or record[crystal] or player:GetAttribute("IslandFinished") then return end
record[crystal] = true
local count = math.min(TARGET, (player:GetAttribute("IslandCount") or 0) + 1)
player:SetAttribute("IslandCount", count)
if count == TARGET then
player:SetAttribute("IslandMessage", "集齐啦!前往能量终端,按提示完成任务。")
else
player:SetAttribute("IslandMessage", "找到 " .. crystal.Name .. ",继续寻找其他晶石。")
end
end)
end
prompt.Triggered:Connect(function(player)
if not collected[player] or not near(player, terminal, 12) then return end
local now = os.clock()
if now - (lastUse[player] or -math.huge) < 0.5 then return end
lastUse[player] = now
if player:GetAttribute("IslandFinished") then return end
local count = player:GetAttribute("IslandCount") or 0
if count < TARGET then
player:SetAttribute("IslandMessage", "还差 " .. (TARGET - count) .. " 颗不同晶石。")
return
end
player:SetAttribute("IslandFinished", true)
player.leaderstats.Medals.Value += 1
player:SetAttribute("IslandMessage", "任务完成!你获得了一枚探险奖章。")
print(player.Name, "finished Energy Island")
end)
Step by step
HUD 只读取服务器状态,不发奖也不判定任务完成。初始化时先监听变化,再 render 一次,以免界面打开时已经错过早先的状态。
面板宽度取屏幕 84%,再限制最大宽度;文字保持固定字号并允许换行。这样比把一长串提示硬塞进一个很小的框更容易读。
local player = game:GetService("Players").LocalPlayer
local gui = Instance.new("ScreenGui")
gui.Name = "IslandGui"
gui.ResetOnSpawn = false
local panel = Instance.new("Frame")
panel.Position = UDim2.fromOffset(16, 16)
panel.Size = UDim2.new(0.84, 0, 0, 132)
panel.BackgroundColor3 = Color3.fromRGB(18, 35, 55)
panel.Parent = gui
local limit = Instance.new("UISizeConstraint")
limit.MaxSize = Vector2.new(420, 132)
limit.Parent = panel
local corner = Instance.new("UICorner")
corner.CornerRadius = UDim.new(0, 12)
corner.Parent = panel
local label = Instance.new("TextLabel")
label.Position = UDim2.fromOffset(12, 10)
label.Size = UDim2.new(1, -24, 1, -20)
label.BackgroundTransparency = 1
label.TextColor3 = Color3.fromRGB(240, 247, 255)
label.TextSize = 20
label.TextWrapped = true
label.TextXAlignment = Enum.TextXAlignment.Left
label.Parent = panel
gui.Parent = player:WaitForChild("PlayerGui")
local function render()
local count = player:GetAttribute("IslandCount")
local finished = player:GetAttribute("IslandFinished")
local message = player:GetAttribute("IslandMessage") or "等待任务数据…"
local heading = finished and "探险完成!" or "能量岛探险"
label.Text = heading .. "\n晶石 " .. tostring(count or 0) .. "/3\n" .. message
end
for _, name in ipairs({"IslandCount", "IslandFinished", "IslandMessage"}) do
player:GetAttributeChangedSignal(name):Connect(render)
end
render()
Step by step
“通关了一次”还不足以证明任务可靠。你也要尝试没集齐、重复领取和重复提交,确认规则在这些情况下仍成立。
两名玩家可以共用相同晶石,但每个人各有进度。晶石不会全局消失,这是这份项目有意选择的规则。

Step by step
先保存 v01,再挑一项扩展:更清楚的路标、完成粒子或温和夜景。不要同时加入计时、存档和商店。
本课的成品是课堂原型,不承诺完整线上反作弊或持久奖励。下一课会把测试、设备体验和安全分享收尾。
Homework
按验收表记录六种情况,发现问题先修复再美化。
给能量岛加入独特路标或故事背景,但不改变核心对象名。
在服务器确认首次完成后播放一次粒子,不在每次 HUD 刷新时播放。
Exit questions
不能,终端由服务器读取它自己的进度并验证条件。
IslandFinished 在发奖前设为 true,之后的提交会提前返回。
重生保留同一个 Player 的任务记录;Stop 后新会话重新初始化。
不需要,先完成最小闭环,再一次增加一个经过验证的功能。
Explore further
正文代码可以直接复制。先确认脚本类型、放置位置,以及这段代码是完整版本还是小实验;不要把同一脚本的多个版本叠加运行。界面参考图已标注来源,图中的对象名可能与本课不同,请以操作步骤为准。