iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Software Development

Roblox Studio AI 協作開發大全系列 第 18

第 18 章:存檔系統

  • 分享至 

  • xImage
  •  

第 14 章建立了任務與 Gold,第 16 章建立了 WindDash,第 17 章讓玩家用 Gold 購買升級。現在這些進度都還只是單次 session 裡的狀態。玩家離開遊戲再回來,Gold、任務進度、升級狀態都會消失。

本章要加入第一版存檔系統。這是全書第一個真正接觸雲端資料的章節,所以節奏要放慢一點。DataStore 不是普通 table,它是網路服務:會失敗、有限制、會 throttle,也可能因為測試方式不對而污染正式資料。

本章目標不是做出完美資料平台,而是建立一個能保存目前主線進度、能處理失敗、能區分測試資料與正式資料的 SaveService

本章資料只來自 gameplay 與 Gold 商店。第 34 章接入 Developer Product 時,會把 schema 升級到 entitlement、消耗品餘額與 processed receipt 紀錄;真實付款資料需要比一般進度更嚴格的原子更新、重試與重複交付防護。

本章目標

完成本章後,你會得到:

  • 一個 server-side SaveService
  • 一個 AIAdventureIsland_PlayerData_DEV_v1 DataStore。
  • 玩家進入時載入資料。
  • 玩家離開時保存資料。
  • server shutdown 時嘗試保存仍在線上的玩家。
  • Gold 可以跨 session 保留。
  • GuideQuestState 可以跨 session 保留。
  • WindDashCooldownLevelWindDashSpeedLevel 可以跨 session 保留。
  • DataStore 呼叫都有 pcall 與錯誤訊息。
  • 一份 Studio 測試 DataStore 的安全檢查清單。

Creator Hub 的 Data Stores Manager 頁面。

圖 18-1 Data Stores Manager 可用來檢查線上資料;測試存檔時要區分 Studio 測試資料與正式環境資料。

Context Pack

本章使用下列 Roblox 官方文件作為 context:

本章採用這些文件中的幾個原則:

  • DataStore 只能從 server-side Script 存取。
  • Studio 測試 DataStore 前要啟用 Enable Studio Access to API Services,但不要在 live game 上直接測正式資料。
  • DataStore 呼叫是網路請求,必須用 pcall 處理失敗。
  • 相關玩家資料應盡量保存成單一 object。
  • DataStore 有 request limits,不能把每一次數值變動都直接寫入雲端。
  • 發布後要用 Data Stores dashboard 觀察錯誤與用量。

Creator Hub Data Store 儀表板顯示各 API 的 request count。

圖 18-2 發布後用 Data Store observability 觀察各 API 的請求量;異常尖峰可能代表重複存檔、重試失控或即將觸發限制。

開場情境

目前 AI Adventure Island 有三類需要保存的資料:

Gold
任務狀態
升級狀態

先不要保存玩家位置、敵人狀態、目前 HUD 文字、cooldown 倒數或臨時 UI。這些資料不是長期進度,保存它們只會增加錯誤風險。

本章使用第一版 schema:

{
	SchemaVersion = 1,
	Gold = 0,
	GuideQuestState = "NotStarted",
	Upgrades = {
		WindDashCooldownLevel = 0,
		WindDashSpeedLevel = 0,
	},
}

DataStore 名稱先用:

AIAdventureIsland_PlayerData_DEV_v1

DEV 很重要。你在 Studio 測試時會產生很多不穩定資料,甚至可能反覆清空、改 schema、手動加 Gold。如果一開始就用正式 store 名稱,很容易把測試過程變成未來難以清理的 production data。

第一個 Prompt

請 Assistant 建立第一版 SaveService

【Ch18 主任務|建立雲端存檔】
我們正在繼續製作 Roblox Studio 專案「AI Adventure Island」。

現有背景:
- 玩家已有 leaderstats.Gold。
- QuestService 將 GuideQuestState 儲存為玩家 attribute。
- ShopService 將 WindDashCooldownLevel 與 WindDashSpeedLevel 儲存為玩家 attribute。
- AbilityService 會讀取這些升級 attribute。
- 本章不要加入交易、背包、排行榜、OrderedDataStore 或 analytics。

建立第一版持久化存檔系統:
1. 建立 ServerScriptService.SaveService。
2. 只在這個 server Script 中使用 DataStoreService。
3. 開發測試使用以下 DataStore 名稱:
   - AIAdventureIsland_PlayerData_DEV_v1
4. 玩家資料的 key 使用:
   - "Player_" .. player.UserId
5. 將所有玩家進度存入同一個 table:
   - SchemaVersion
   - Gold
   - GuideQuestState
   - Upgrades.WindDashCooldownLevel
   - Upgrades.WindDashSpeedLevel
6. 提供一個建立預設資料的 function:
   - SchemaVersion = 1
   - Gold = 0
   - GuideQuestState = "NotStarted"
   - WindDashCooldownLevel = 0
   - WindDashSpeedLevel = 0
7. 在 Players.PlayerAdded 時:
   - 在 pcall 內使用 GetAsync 讀取玩家資料
   - 如果資料存在且型別是 table,就套用該資料
   - 如果資料不存在,就套用預設資料
   - 如果 GetAsync 失敗,套用預設資料並在 Output 顯示警告
8. 套用資料時應:
   - 確保 leaderstats.Gold 存在
   - 設定 Gold.Value
   - 設定玩家的 GuideQuestState、WindDashCooldownLevel、WindDashSpeedLevel attributes
9. 在 Players.PlayerRemoving 時:
   - 將目前的 Gold 與 attributes 整理成同一個 table
   - 在 pcall 內使用 UpdateAsync 或 SetAsync 保存
   - 成功時印出訊息,失敗時顯示警告
10. 加入 game:BindToClose,讓 server 關閉時嘗試保存所有仍在線上的玩家。
11. 不要在每次 Gold 改變時保存。
12. 不要建立任何讓 client 請求保存的 RemoteEvent。
13. 現階段不要使用 production store 名稱。

另外加入註解說明:
- 如何啟用 Studio API access 以進行測試
- 為什麼使用 DEV store 名稱
- 為什麼 DataStore 呼叫需要 pcall

這個 prompt 比一般「幫我做存檔」長很多,原因是存檔系統的錯誤代價比較高。Assistant 如果只生成 happy path,Play Solo 看起來可能正常,但一遇到 throttle、API 未啟用、server 關閉或資料格式改變,就會出問題。

這句尤其重要:

不要建立任何讓 client 請求保存的 RemoteEvent。

玩家不需要告訴 server「請幫我存檔」。server 本來就知道權威資料,應該由 server 決定何時保存。

Assistant 可能產生的結果

Assistant 可能建立:

ServerScriptService
└── SaveService

第一版 SaveService 可能像這樣:

local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")

local STORE_NAME = "AIAdventureIsland_PlayerData_DEV_v1"
local DATA_SCHEMA_VERSION = 1

local playerDataStore = DataStoreService:GetDataStore(STORE_NAME)

local function getPlayerKey(player)
	return "Player_" .. player.UserId
end

local function getDefaultData()
	return {
		SchemaVersion = DATA_SCHEMA_VERSION,
		Gold = 0,
		GuideQuestState = "NotStarted",
		Upgrades = {
			WindDashCooldownLevel = 0,
			WindDashSpeedLevel = 0,
		},
	}
end

applyData() 負責把讀出的資料套回目前 server 狀態:

local function ensureLeaderstats(player)
	local leaderstats = player:FindFirstChild("leaderstats")
	if not leaderstats then
		leaderstats = Instance.new("Folder")
		leaderstats.Name = "leaderstats"
		leaderstats.Parent = player
	end

	local gold = leaderstats:FindFirstChild("Gold")
	if not gold then
		gold = Instance.new("IntValue")
		gold.Name = "Gold"
		gold.Value = 0
		gold.Parent = leaderstats
	end

	return leaderstats, gold
end

local function applyData(player, data)
	local _, gold = ensureLeaderstats(player)

	gold.Value = tonumber(data.Gold) or 0
	player:SetAttribute("GuideQuestState", data.GuideQuestState or "NotStarted")

	local upgrades = data.Upgrades or {}
	player:SetAttribute("WindDashCooldownLevel", tonumber(upgrades.WindDashCooldownLevel) or 0)
	player:SetAttribute("WindDashSpeedLevel", tonumber(upgrades.WindDashSpeedLevel) or 0)
end

collectData() 則把目前 server 狀態整理成可保存的 table:

local function collectData(player)
	local leaderstats = player:FindFirstChild("leaderstats")
	local gold = leaderstats and leaderstats:FindFirstChild("Gold")

	return {
		SchemaVersion = DATA_SCHEMA_VERSION,
		Gold = gold and gold.Value or 0,
		GuideQuestState = player:GetAttribute("GuideQuestState") or "NotStarted",
		Upgrades = {
			WindDashCooldownLevel = player:GetAttribute("WindDashCooldownLevel") or 0,
			WindDashSpeedLevel = player:GetAttribute("WindDashSpeedLevel") or 0,
		},
	}
end

載入時要用 pcall

local function loadPlayerData(player)
	local key = getPlayerKey(player)

	local success, data = pcall(function()
		return playerDataStore:GetAsync(key)
	end)

	if success and typeof(data) == "table" then
		applyData(player, data)
		print("Loaded data for", player.Name)
		return
	end

	if success and data == nil then
		applyData(player, getDefaultData())
		print("Applied default data for", player.Name)
		return
	end

	warn("Failed to load data for", player.Name, data)
	applyData(player, getDefaultData())
end

保存時可以先用 UpdateAsync 保存整個 object:

local function savePlayerData(player)
	local key = getPlayerKey(player)
	local dataToSave = collectData(player)

	local success, errorMessage = pcall(function()
		playerDataStore:UpdateAsync(key, function(_oldData)
			return dataToSave
		end)
	end)

	if success then
		print("Saved data for", player.Name)
	else
		warn("Failed to save data for", player.Name, errorMessage)
	end
end

最後接上玩家生命週期:

Players.PlayerAdded:Connect(loadPlayerData)

Players.PlayerRemoving:Connect(function(player)
	savePlayerData(player)
end)

game:BindToClose(function()
	for _, player in Players:GetPlayers() do
		savePlayerData(player)
	end
end)

這是第一版存檔,不是最終架構。它還沒有 retry queue、session lock、資料遷移、多 profile、離線回復等功能。但對本書目前的遊戲進度來說,它已經足夠建立正確觀念:資料集中、server 權威、失敗處理、測試資料隔離。

元件拆解

DataStoreService

DataStoreService 是 Roblox 提供的持久化儲存服務。它可以讓資料跨 session 保存,例如玩家金幣、升級、任務進度。

本章只在 ServerScriptService.SaveService 使用它:

local DataStoreService = game:GetService("DataStoreService")

不要在 LocalScript 使用 DataStoreService。client 不應該直接讀寫持久資料。

Store Name

本章使用:

AIAdventureIsland_PlayerData_DEV_v1

命名包含三件事:

  • 專案名:AIAdventureIsland
  • 資料用途:PlayerData
  • 環境與版本:DEV_v1

等你準備發布正式版時,可以建立正式 store:

AIAdventureIsland_PlayerData_PROD_v1

不要只是把測試資料拿去當正式資料。測試時你可能手動加錢、重複購買、破壞 schema,這些資料不應成為玩家正式進度。

Data Key

每位玩家使用一個 key:

"Player_" .. player.UserId

這比使用玩家名稱穩定。玩家名稱可能變更,UserId 才是帳號的長期識別。

DataStore key 有長度限制,所以 key 要簡短、可預期。

Single Object

本章把 Gold、任務狀態、升級狀態存在同一個 table。

不要一開始就拆成:

GoldStore
QuestStore
UpgradeStore

拆太多 store 會增加一致性問題。例如 Gold 扣了,但升級狀態保存失敗,玩家下次回來就會少錢又沒升級。單一 player data object 比較容易保持一致。

pcall

DataStore 呼叫可能失敗。原因可能是:

  • Studio API access 沒開。
  • 網路或 Roblox backend 暫時錯誤。
  • request 被 throttle。
  • queue 滿了。
  • key 或資料格式不合法。

所以不要這樣寫:

local data = playerDataStore:GetAsync(key)

要這樣:

local success, data = pcall(function()
	return playerDataStore:GetAsync(key)
end)

pcall 不是讓錯誤消失,而是讓你有機會記錄錯誤、套用 fallback,避免整個 script 因為一次失敗中斷。

GetAsync / SetAsync / UpdateAsync

GetAsync 讀取資料。

SetAsync 直接寫入資料,簡單但遇到多 server 同時寫同一 key 時比較容易覆蓋。

UpdateAsync 讀目前值、執行 callback、再寫入新值。它比較慢,也消耗讀寫 budget,但更適合處理同一 key 可能被多個 server 更新的情況。

本章使用 UpdateAsync 保存整個 player data object。callback 裡不要 task.wait() 或呼叫會 yield 的函式。

Enable Studio Access to API Services

要在 Studio 測 DataStore,必須:

  1. 發布一個測試用 experience。
  2. 打開 File -> Experience Settings
  3. 進入 Security
  4. 開啟 Enable Studio Access to API Services
  5. 儲存設定。

但這個設定有風險。Studio 會連到同一個 experience 的 DataStore,所以正式遊戲不要直接用 Studio 測 production store。最安全的流程是建立測試版 experience 或使用 DEV store 名稱。

BindToClose

玩家正常離開時會觸發 Players.PlayerRemoving。但 server 關閉時,仍然可能有玩家在線上。

本章用:

game:BindToClose(function()
	for _, player in Players:GetPlayers() do
		savePlayerData(player)
	end
end)

這能提高 server shutdown 時保存資料的機率。不過它不是萬能保證,所以重要遊戲通常還會加定期 autosave 與 retry。這些可以放到後面工程整理章節。

Playtest

DataStore 測試要比一般功能更謹慎。

第一輪:未啟用 API access

  1. 在 Studio 按 Play。
  2. 觀察 Output。
  3. 如果看到 API access 相關錯誤,不要急著修程式。
  4. 確認這是因為 Studio 尚未允許 DataStore access。

這一輪的目的,是讓讀者知道「API 沒開」不是 script 一定寫錯。

第二輪:DEV store 載入預設資料

  1. 開啟測試用 experience 的 API access。
  2. 確認 store name 是 AIAdventureIsland_PlayerData_DEV_v1
  3. 按 Play。
  4. 第一次進入應套用 default data。
  5. Gold 應為 0。
  6. GuideQuestState 應為 NotStarted

第三輪:保存與重新載入

  1. 在測試中手動或透過任務取得 Gold。
  2. 購買一個 WindDash 升級。
  3. 停止 Play,等待 PlayerRemoving 保存。
  4. 再次 Play。
  5. 確認 Gold 與升級 attribute 被載回。

第四輪:server shutdown

  1. 使用 Play 測試並修改 Gold。
  2. Stop Play。
  3. 觀察 Output 是否出現保存訊息。
  4. 再次 Play 確認資料是否保存。

第五輪:壞資料防護

如果你透過 Data Stores Manager 手動修改測試資料,故意少掉 Upgrades table,重新進入時不應直接 error。applyData() 應使用 default fallback。

修正 Prompt

如果 Assistant 在 LocalScript 使用 DataStoreService,使用:

【Ch18 修正 1|移除 Client DataStore】
目前有 LocalScript 在使用 DataStoreService。

預期結果:
- DataStoreService 只能在 ServerScriptService.SaveService 中使用。
- Client 不應直接讀取或寫入 DataStore。
- 不應有任何 RemoteEvent 讓 client 請求保存。

請將所有 DataStore 程式碼移到 server Script,並讓 client 完全不參與存檔流程。

如果沒有 pcall,使用:

【Ch18 修正 2|補上 DataStore pcall】
SaveService 呼叫 GetAsync、SetAsync 或 UpdateAsync 時沒有使用 pcall。

預期結果:
- 每一次 DataStore request 都應包在 pcall 中。
- 載入失敗時,套用預設資料並在 Output 顯示警告。
- 保存失敗時,在 Output 顯示警告,且不要讓 script crash。

請只更新 SaveService。

如果每次 Gold 改變都保存,使用:

【Ch18 修正 3|停止逐次寫入 DataStore】
SaveService 在每次 Gold 改變時都寫入 DataStore。

預期結果:
- 不要在每次 Gold 改變時保存。
- 在 PlayerRemoving 時保存。
- 在 BindToClose 時保存。
- 之後可以選擇加入間隔較長的 autosave,但不能在每次數值改變時保存。

請移除數值一改變就立即寫入的行為,以避免 DataStore throttling。

如果測試資料和正式資料混在一起,使用:

【Ch18 修正 4|改用 DEV DataStore】
目前仍在 Studio 測試,但存檔系統使用了看起來像正式環境的 DataStore 名稱。

預期結果:
- 本章使用 AIAdventureIsland_PlayerData_DEV_v1。
- 加入註解,說明只有完成 release testing 後才能使用 PROD store 名稱。
- 本章不要進行資料遷移。

如果重新進入後升級沒有載回,使用:

【Ch18 修正 5|載回 WindDash 升級】
Gold 可以正確載入,但玩家重新加入後,WindDash 升級 attributes 沒有載入。

預期結果:
- SaveService 應保存並載入:
  - WindDashCooldownLevel
  - WindDashSpeedLevel
- applyData 應設定這些玩家 attributes。
- collectData 應從玩家讀取這些 attributes。

請只檢查 SaveService。

工程整理

完成本章後,Explorer 應接近這樣:

ServerScriptService
├── QuestService
├── EnemyAIService
├── AbilityService
├── ShopService
└── SaveService

SaveService 不應該在 client,也不應該散落到 QuestServiceShopServiceAbilityService 裡。

整理時檢查:

  • DataStoreService 只出現在 server。
  • store name 是 DEV_v1
  • data key 使用 Player_<UserId>
  • 所有進度存在單一 table。
  • DataStore 呼叫都有 pcall
  • UpdateAsync callback 不 yield。
  • 沒有 client-triggered save RemoteEvent。
  • 沒有保存臨時 UI、cooldown 倒數或敵人狀態。

本章完成後,第四部的主線功能已經有一個完整閉環:

任務給 Gold
Gold 買升級
升級強化能力
能力幫助玩家通過敵人區
進度可以保存

本章總結

本章讓 AI Adventure Island 第一次具備跨 session 進度。

你建立了:

  • SaveService
  • AIAdventureIsland_PlayerData_DEV_v1
  • 單一 player data schema。
  • load / apply / collect / save 流程。
  • PlayerRemoving 保存。
  • BindToClose 保存。
  • pcall 錯誤處理。
  • Studio DataStore 測試安全流程。

最重要的觀念是:DataStore 不是一般變數。它是有限制、會失敗、需要隔離測試資料的雲端服務。

下一章會進入第五部,開始整理 AI 生成後的專案架構。到目前為止,我們已經用 Assistant 生成了很多功能;接下來要把這些 service、module、remote、UI controller 重新檢查成可維護的長期結構。

第八部會再回到存檔邊界:第 34 章處理 receipt 與 entitlement,第 36–37 章則只觀測成功交易,不讓 analytics 取代可靠保存。


關於 Wolke

嗨!我是 Wolke,曾任 Google Developer Expert(GDE,2019–2023)LINE API Expert

我熱衷於研究 AI Agent、n8n 自動化工作流與全端開發架構,致力於將 AI 技術轉化為真正能落地的生產力工具。

如果你喜歡這篇文章,歡迎透過以下方式與我交流:

📚 技術著作
《實用的 Gemini API 開發點子書》:帶你運用 Gemini App、Google AI Studio、Gemini CLI 與 Antigravity IDE,打造 AI Agent 與實用產品。

📝 技術部落格
歡迎追蹤我的 Medium,我會持續分享 Agentic Automation、架構設計與實際開發的踩坑心得。

🎤 技術講座與合作
我持續受邀至技術社群及研討會,分享 AI Agent、自動化工作流、DevOps 與全端開發實戰。

我曾於 DevOpsDays Taipei 2026 主講「不再只是寫腳本!讓 AI 代理人成為你的 SRE 最佳夥伴」工作坊。

如果你的企業、社群或學校正在尋找相關主題講者,歡迎私訊聯繫,洽談講座與工作坊合作!

🎮 我的 Roblox 遊戲

🎁 免費贈送 OpenAI 或 Claude AI 額度

為了鼓勵大家實際動手打造自己的 Roblox 體驗,我每個月會開放:

  • 10 個名額
  • 每人 50 點 AI 額度
  • 名額送完為止

參加方式:

  1. 訂閱本系列文章。
  2. 分享任一篇系列文章。
  3. 私訊分享截圖及你的 AI 帳號 Email。

確認完成後,我會邀請你加入並設定 50 點額度。名額有限,歡迎把握機會!


上一篇
第 17 章:商店與升級系統
下一篇
第 19 章:把 AI 生成的程式整理成可維護架構
系列文
Roblox Studio AI 協作開發大全23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言