實現玩家數據和購買系統

*此內容是使用 AI(Beta 測試版)翻譯,可能含有錯誤。若要以英文檢視此頁面,請按一下這裡

背景

Roblox 提供一組 API 與數據存儲通過 DataStoreService 進行接口。這些 API 最常見的使用案例是儲存、載入和複製 玩家資料 。即,與玩家進度、購買和其他會話特性相關的資料,會在個別遊戲會話之間持續存在。

Roblox 上的大多數體驗使用這些 API 來實現某種形式的玩家數據系統。這些實現方式在方法上各不相同,但一般都努力解決相同的問題集。

常見問題

以下是玩家數據系統嘗試解決的最常見問題之一:

  • 在記憶存取中:: DataStoreService 要求使用異步操作的網路請求,並受到速率限制。這適合在會作業開始時的初始載入,但不適合在遊遊玩正常進行期間的高頻度讀取和寫入操作。大多數開發者的玩家數據系統會將此數據存儲在 Roblox 服務伺服器的內存中,限制 DataStoreService 請求轉化為以下情況:

    • 會作業開始時的初始閱讀
    • 會作業結束時的最後寫入
    • 定期在間隔時間寫入以減少最後一次寫入失敗的情況
    • 在處理購買時確保資料被保存
  • 高效存儲: 將玩家的所有會話數據存入單個表中,可以讓您在單次更新中更新多個值,並在更少的請求中處理相同數量的數據。它也移除了間值同步化的風險,並使回退更容易理解。

    一些開發者也會實裝自訂序列化來壓縮大型數據結構 (通常用於儲存遊戲內用戶生成的內容)。

  • 複製:: 客戶端需要定期存取玩家的資料(例如,更新介面)。將玩家數據複製到客戶端的一般方法可讓您無需創建特定的數據複製系統來傳送此信息。開發者經常想要選擇性地將什麼複製到客戶端,什麼不複製到客戶端。

  • 錯誤處理: 當數據儲存無法訪問時,大多數解決方案會實現重試機制和"預設"數據的備份。為了確保緩解數據不會稍後覆蓋「真實」數據,並將此通知給玩家,需要特別關注。

  • 重試: 當數據儲存無法訪問時,大多數解決方案會實現重試機制和向預設數據的退回。特別注意確保緩解數據不會擊敗「真實」數據,並適當地向玩家通知情況。

  • 會話鎖定: 如果單一玩家的資料被載入並保存在多個伺服器上,可能發生一個伺服器儲存過期資訊的問題。這可能會導致數據損失和常見項目複製漏洞。

  • 原子購買處理:: 驗證、授予和記錄購買原子以防止物品遺失或多次授予。

範例代碼

Roblox 有參考代碼可幫助您設計和構建玩家數據系統。本頁剩餘部分探討背景、實作細節和一般限制。


在將模型匯入 Studio 之後,您應該看到以下夾子結構:

Explorer window showing the purchasing system model.

架構

這個高層圖解釋樣本中的關鍵系統以及它們如何與體驗的其他部分的代碼進行接口。

An architecture diagram for the code sample.

重試

類別::

背景

因為 DataStoreService 在後台進行網路請求,所以它的請求並不一定會成功。當發生這種情況時, DataStore 方法會發生錯誤,允許您處理它們。

如果您嘗試像這樣處理數據存儲失敗,就可能發生一個常見的「陷阱」:


local function retrySetAsync(dataStore, key, value)
for _ = 1, MAX_ATTEMPTS do
local success, result = pcall(dataStore.SetAsync, dataStore, key, value)
if success then
break
end
task.wait(TIME_BETWEEN_ATTEMPTS)
end
end

雖然這是一個普通功能的完全有效的重試機制,但它不適合 DataStoreService 請求,因為它不保證請求的順序。保持請求的順序對於 DataStoreService 請求非常重要,因為它們會與狀態互動。考慮以下情況:

  1. 請求 A 被用來設置鑰匙值 K 為 1。
  2. 請求失敗,因此在 2 秒內預定進行重試。
  3. 在重試發生之前,請求B設置K值為2,但請求A的重試立即覆蓋此值並將K設置為1。

雖然 UpdateAsync 操作在最新版本的鑰鍵值上,但 UpdateAsync 請求仍然必須被處理,以避免無效的暫時狀態(例如,購買會在金幣添加之前減少金幣,導致負金幣)。

我們的玩家數據系統使用新類別,DataStoreWrapper,提供保證按鍵順序處理的退試機會。

方法

An process diagram illustrating the retry system

DataStoreWrapper 提供對應 DataStore 方法的方法:DataStore:GetAsync()DataStore:SetAsync()DataStore:UpdateAsync()DataStore:RemoveAsync()

當這些方法被呼叫時:

  1. 將請求添加到隊列。每個鑰匙都有自己的隊列,其中請求會按順序和列表處理。要求線程會持續到請求完成為止。

    這項功能基於 ThreadQueue 類別,它是一個基於競爭的任務排程和速率限制器。而不是返回一個承諾,ThreadQueue 會在操作完成之前保留現有線程,如果失敗會投擲錯誤。這與使用 idiomatic 非同步 Luau 模式的模式更一致。

  2. 如果請求失敗,它將使用可配置的指數退出來重試。這些重試的一部分是提交給 ThreadQueue 的回呼中的一部分,因此在此鑰匙的下一個請求開始之前,它們必須完成。

  3. 當請求完成時,請求方法返回 success, result 模式

DataStoreWrapper 也暴露方法來獲得給定鍵的隊列長度和清除過期請求。後者選項在伺服器關閉時和沒有時間處理最新請求的情況下特別有用。

警告

DataStoreWrapper 遵循原則,即在極端情況之外,每個數據存儲請求都應允許完成(成功或其他方式),即使最新的請求使其過剩。當新請求發生時,過期的請求不會從隊列中移除,而是允許在新請求開始之前完成。這種做法的原因在於這個模組可以作為一般的數據儲存工具,而不是專門為玩家數據設計的特定工具,原因如下:

  1. 當要求安全地從隊列中移除時,很難決定一組直觀的規則。考慮以下隊列:

    Value=0, SetAsync(1), GetAsync(), SetAsync(2)

    預期的行為是,GetAsync()將返回1,但如果我們因為最新一個而使其變得多餘而從隊列中移除SetAsync()請求,它將返回0

    邏輯進度是當新的寫入請求被添加時,只要刪除最新閱讀請邀請的最遠要求。UpdateAsync(),遠比最常見的操作(並且只有這個系統使用的操作),兩者都可以閱讀和寫入,因此在這個設計中無需添加額外的複雜度就很難達成。

    DataStoreWrapper 可能需要您指定是否允許 UpdateAsync() 請求閱讀和/或寫入,但它對我們的玩家數據系統沒有適用,因為此無法在會話鎖定機制(稍後詳細討論)中預先確定。

  2. 一旦從隊列中移除,就很難決定對 如何 這應該如何處理的直觀規則。當發出 DataStoreWrapper 請求時,現有線程將被釋放直到完成為止。如果我們從隊列中移除過期的請求,我們就必須決定是否返回 false, "Removed from queue" 或永遠不返回並丟棄已啟用的執行緒。兩種方法都有自己的缺點,並將額外複雜度轉嫁給消費者。

最終,我們的觀點是簡單的方法(處理每個請邀請)在這裡更適合,並創建了更清晰的環境來導航複雜的問題,例如針對會話鎖定的問題。唯一的例外是在 DataModel:BindToClose() 期間,當清除隊列變得必要以保存所有用戶的數據時,值個別函數呼叫返回的問題已不再是持續關注的問題。為了補償這一點,我們暴露了一個 skipAllQueuesToLastEnqueued 方法。欲了解更多上下文,請參閱玩家資料

會話鎖定

類別::

背景

玩家數據存儲在服務器內存中,只在必要時從原始數據存儲中讀取和寫入數據。您可以立即在記憶體中閱讀和更新玩家資料,不需要網路請求,並避免超出 DataStoreService 限制。

為了這個模型正常運作,必須確保在同一時間從 DataStore 中載入玩家數據的服務器不超過一個。

例如,如果伺服器 A 載入玩家的資料,伺服器 B 在最後儲存期間不能載入該資料,直到伺服器 A 釋放該資料的鎖定。沒有鎖定機制,伺服器 B 可以在伺服器 A 有機會儲存最新版本之前,從資料儲存中載入過期的玩家資料。然後,如果伺服器 A 在伺服器 B 載入過期數據後保存新數據,伺服器 B 在下次保存時將覆蓋該新數據。

雖然 Roblox 只允許一個客戶一次連接到一個伺服器,但你不能假設一個會話的資料在下一個會話開始之前總是會保存。考慮以下情況,當玩家離開伺服器 A 時可能發生:

  1. 伺服器 A 向保存其資料發出 DataStore 請求,但請求失敗,需要多次重試才能成功完成。在重試期間,玩家加入伺服器 B。
  2. 伺服器 A 做了太多 UpdateAsync() 呼叫同一個鑰匙,並被限制。最後的儲存請求被放置在隊列中。當請求在隊列中時,玩家加入伺服器 B。
  3. 在伺服器 A 上,一些連接到 PlayerRemoving 事件的代碼在玩家的數據保存之前產生。在此操作完成之前,玩家加入伺服器 B。
  4. 伺服器 A 的性能已降至至少延遲到玩家加入伺服器 B 之後才能儲存最後一次的程度。

這些情況應該很罕見,但確實會發生,特別是在玩家從一個伺服器中斷連線並快速連接到另一個伺服器(例如,在傳送時)。一些惡意的使用者甚至可能嘗試濫用此行為來完成行動,而不持續。這可能在允許玩家交易的遊戲中特別具有影響力,是一個常見的物品複製漏洞來源。

會話鎖定確保當玩家的 DataStore 鍵首先被服務器讀取時,服務器會在同一個 UpdateAsync() 呼叫中原子寫鎖定鍵的元數據。如果此鎖定值在任何其他伺服器嘗試閱讀或寫入鑰匙時存在,伺服器不會繼續。

方法

An process diagram illustrating the session locking system

SessionLockedDataStoreWrapper 是在 DataStoreWrapper 類別周圍的 meta-wrapper。DataStoreWrapper提供排隊和重試功能,其SessionLockedDataStoreWrapper與會話鎖定結合。

SessionLockedDataStoreWrapper 通過每個 DataStore 請求—無論是 GetAsyncSetAsyncUpdateAsync—通過 UpdateAsync 。這是因為 UpdateAsync 允許鑰匙被閱讀和寫入原子狀態。還可以根據閱讀值放棄寫入,以返回 nil 在變換回調中。

傳到 UpdateAsync 的轉換功能對每個請求執行以下操作:

  1. 驗證鑰匙是安全存使用權 通行權 存取的,如果不是,就放棄操作。「安全存使用權 通行權 存取」意味著:

    • 鑰鍵的元數據對象不包含在鎖過期時間少於一年前最後更新的未認識的 LockId 值。這會導致遵守另一個伺服器放置的鎖定並忽略其過期的鎖定。

    • 如果此伺服器以前在鑰鍵的元數據中放置了自己的 LockId 值,則此值仍然在鑰鍵的元數據中。這會解釋另一個伺服器已經接管了這個伺服器的鎖(通過過期或強制),然後釋放它的情況。換言之,即使 LockIdnil,另一個伺服器仍然可以在你鎖定鑰鍵的時間後替換和移除一個鎖定。

  2. UpdateAsync 執行 DataStore 操作,消費者的 SessionLockedDataStoreWrapper 請求了。例如,GetAsync() 翻譯為 function(value) return value end

  3. 根據傳到請邀請的參數,UpdateAsync 會將鑰鍵鎖定或解鎖:

    1. 如果要鎖定鑰匙,UpdateAsync將鑰鍵的元數據設置為LockId的GUID。此GUID在伺服器上存儲在記憶中,因此下次使用時可以驗證它存取鑰鍵。如果伺服器已經對此鍵有鎖定,它不會做出任何變更。它也會安排任務來警告您,如果您未再使用鑰匙來維持鎖定在鎖定時間內的鎖定狀態。

    2. 如果要解鎖鑰匙, UpdateAsync 移除鑰鍵的元數據中的 LockId

傳入基礎 DataStoreWrapper 中的自訂重試處理器,以便在步驟 1 因會話鎖定而中斷操作後進行重試。

還會將自訂錯誤訊息返回給消費者,讓玩家資料系統在客戶端鎖定期間報告替代錯誤。

警告

會話鎖定模式依據伺服器在完成後永遠釋放鎖定鑰匙的服務器來實現。這應該始終通過指令來解鎖鑰匙,作為最後寫入的一部分在 PlayerRemovingBindToClose() 中。

但是,在某些情況下解鎖可能會失敗。例如:

  • 伺服器崩潰了或 DataStoreService 無法使用所有嘗試存取鑰鍵的操作。
  • 由於邏輯或類似錯誤,未執行釋放鑰匙的指令。

要維持鑰鍵上的鎖定,您必須定期存取它,直到它在記憶中載入為止。這通常會在大多數玩家數據系統的背景中執行自動保存循環中,但此系統也會在需要手動執行時暴露 refreshLockAsync 方法。

如果鎖定時間已過期而未更新鎖定,任何伺服器都可以免費接管鎖定。如果不同的伺服器擷取鎖定,現有伺服器嘗試讀取或寫入鑰匙將失敗,除非建立新鎖。

開發者產品處理

單一:: ReceiptHandler

背景

回調 ProcessReceipt 會執行確定何時結束購買的關鍵工作。ProcessReceipt 在非常特定的情況下被稱為。對於其保證組,請參閱 MarketplaceService.ProcessReceipt

雖然處理購買的定義可能在體驗之間不同,但我們使用以下標準

  1. 購買尚未以前處理過。

  2. 購買將在當前會作業中反映。

  3. 購買已儲存到 DataStore

    每次購買,即使是一次性消耗品,也應反映在 DataStore 中,以便用戶的購買歷史包含在他們的會話數據中。

這需要在返回 PurchaseGranted 之前執行以下操作:

  1. 驗證 PurchaseId 尚未記錄為已處理。
  2. 授予玩家內存玩家資料中的購買。
  3. 記錄 PurchaseId 在玩家內存中處理的玩家數據。
  4. 寫入玩家的內存玩家資料到 DataStore

會話鎖定簡化了這個流程,因為您不再需要擔心以下情況:

  • 當前伺服器中的記憶體玩家資料可能過時,需要您先從 DataStore 中取得最新值,然後才能驗證 PurchaseId 歷史
  • 在另一個伺服器上運行的相同購買的回呼,需要你同時閱讀和寫 PurchaseId 歷史記錄,並儲存反映到購買中的更新玩家資料,以防止競爭狀態

會話鎖定保證,如果嘗試寫入玩家的 DataStore 成功,沒有其他伺服器成功閱讀或寫入玩家的 DataStore 之間,在這個伺服器載入和儲存數據期間。簡言之,此伺服器中的記憶體玩家資料是最新的可用版本。有一些限制,但它們不會影響此行為。

方法

ReceiptProcessor 中的評論概述方法:

  1. 驗證玩家的資料目前已在此伺服器上載,並且沒有發生任何錯誤。

    因為這個系統使用鎖定會話,這個檢查也會檢查內存資料是最新版本。

    如果玩家的資料尚未載入(當玩家加入遊戲時期望),請等待玩家的資料載入。系統也會聆聽玩家在數據載入之前離開遊戲的聲音,因為它不應該永久生效,並且在玩家重新加入此服務器時再次呼叫此回叫不應該。

  2. 驗證 PurchaseId 尚未在玩家數據中記錄為已處理。

    由於鎖定會話,系統內記憶中的 PurchaseIds 數組是最新版本。如果 PurchaseId 被記錄為已處理並反映在已載入或儲存到 DataStore 的值中,則返回 PurchaseGranted 。如果記錄為已處理,但 反映在 DataStore 中,則返回 NotProcessedYet

  3. 在此伺服器本地更新玩家資料以「獎勵」購買。

    ReceiptProcessor 採用通用回呼方法,並為每個 DeveloperProductId 指派不同的回呼。

  4. 在此伺服器上本地更新玩家資料,以存儲 PurchaseId

  5. 提交請求將內存資料儲存到 DataStore ,如果要求成功,返回 PurchaseGranted ;如果不成功,返回 NotProcessedYet

    如果此儲存請求失敗,稍後請求儲存玩家的內存會話數據仍然可能成功。在下一個 ProcessReceipt 呼叫期間,步驟 2 處理此情況並返回 PurchaseGranted

玩家資料

單例:: ,

背景

提供遊戲代碼接口以同步閱讀和寫入玩家會話數據的模組在 Roblox 體驗中很常見。本節包括 PlayerData.ServerPlayerData.Client

方法

PlayerData.ServerPlayerData.Client 處理以追蹤中內容:

  1. 將玩家的資料載入記憶體,包括在載入失敗的情況下處理案件
  2. 提供用於查詢和更改玩家數據的服務器代碼介面
  3. 將玩家的資料變更複製到客戶端,讓客戶端能夠存取它
  4. 將載入和/或儲存錯誤複製到客戶端,以便它可以顯示錯誤對話
  5. 在玩家離開時、伺服器關閉時定期儲存玩家的資料

載入玩家資料

An process diagram illustrating the loading system
  1. SessionLockedDataStoreWrapper 向資料商店 商家存提出 getAsync 要求。

    如果此請求失敗,將使用預設資料並將個人檔案標為「錯誤」以確保未來不會寫入資料儲存。

    另一個選擇是踢出玩家,但我們建議讓玩家使用預設資料和清除訊息來確認發生了什麼事,而不是從體驗中移除他們。

  2. 初始載入量會被傳送到 PlayerDataClient 包含載入的資料和錯誤狀態(如果有)。

  3. 使用 waitForDataLoadAsync 來生成玩家的任何線程都會被重新啟動。

提供服務器代碼的介面

  • PlayerDataServer 是一個單獨的實體,任何在相同環境中運行的服務器代碼都可以需要和存取它。
  • 玩家資料被組織成一個字典的鑰匙和值。您可以使用 setValuegetValueupdateValueremoveValue 方法在服務器上操作這些值。這些方法都會同步運行,而不會放棄。
  • hasLoadedwaitForDataLoadAsync 方法可用以確保在您存取資料之前資料已載入。我們建議在其他系統開始之前,在載入屏幕上進行一次,以避免在與客戶端上的數據進行每次互動之前檢查載入錯誤。
  • 一個 hasErrored 方法可以查詢玩家的初始載入失敗,導致他們使用預設資料。在允許玩家進行任何購買之前,請檢查此方法,因為購買無法保存到資料,直到載入成功。
  • A playerDataUpdated 信號會在玩家的數據發生變更時發射 player , keyvalue 。個別系統可以訂閱這個。

向客戶端重複變更

  • PlayerDataServer 中對玩家資料進行任何變更,除非該鑰匙使用 setValueAsPrivate 標示為私有,否則會複製到 PlayerDataClient
    • setValueAsPrivate 用於標示不應傳送給客戶端的鑰匙
  • PlayerDataClient 包括取得鑰匙值的方法 (獲取) 和在更新時發射的信號 (更新)。還包括一個 hasLoaded 方法和一個 loaded 信號,因此客戶可以等待數據載入和複製,然後才能啟動其系統
  • PlayerDataClient 是一個單例,可以由任何在相同環境中運行的客戶代碼需要和存取

向客戶重複錯誤

  • 儲存或載入玩家數據時遇到的錯誤狀態會被複製到PlayerDataClient
  • 使用 getLoadErrorgetSaveError 方法以及 loadedsaved 信號存取此信息。
  • 有兩種錯誤類型:DataStoreErrorDataStoreService)和SessionLocked(參見會話鎖定)。
  • 使用這些事件來禁用客戶購買提示和實現警告對話。這張圖顯示了一個示例對話:
A screenshot of an example warning that could be shown when player data fails to load

儲存玩家資料

A process diagram illustrating the saving system
  1. 當玩家離開遊戲時,系統會執行以下步驟:

    1. 檢查是否安全寫入玩家的資料到資料商店 商家存中。可能不安全的情況包括玩家的資料無法載入或仍在載入中。
    2. 通過 SessionLockedDataStoreWrapper 向服務器發出請求寫入當前內存數據值到數據存儲中,完成後移除會話鎖。
    3. 清除玩家的資料 (以及其他變數,例如元數據和錯誤狀態) 從伺服器記憶。
  2. 在定期循環中,伺服器寫入每個玩家的數據到數據存儲(提供安全的情況下)。這種歡迎的重複可以在伺服器崩潰的情況下減少損失,也是維持會話鎖定的必要條件。

  3. 當收到關閉伺服器的請求時,在 BindToClose 回調中會發生以下事件:

    1. 要求將每個玩家的資料儲存在伺服器上,正如通常在玩家離開伺服器時經歷的過程一樣。這些請求並行進行,因為 BindToClose 回呼只有 30 秒才能完成。
    2. 為了加快保存速度,每個鍵的隊列中的所有其他請求都已從底層 DataStoreWrapper 中清除(見 重試)。
    3. 回叫不會在所有請求完成之前返回。