プレイヤーデータと購入システムの実装

*このコンテンツは、ベータ版のAI(人工知能)を使用して翻訳されており、エラーが含まれている可能性があります。このページを英語で表示するには、 こちら をクリックしてください。

背景

Roblox は、DataStoreService を介してデータストアとインターフェースするための一連の API を提供しています。これらの API の最も一般的な使用ケースは、_プレイヤーデータ_の保存、読み込み、複製です。つまり、プレイヤーの進行状況、購入、その他のセッション特性に関連するデータであり、個々のプレイセッション間で持続します。

ほとんどの Roblox のゲームは、これらの API を使用して何らかの形式のプレイヤーデータシステムを実装しています。これらの実装はアプローチにおいて異なりますが、一般的には同じ問題セットを解決しようとしています。

一般的な問題

以下は、プレイヤーデータシステムが解決しようとする最も一般的な問題のいくつかです。

  • メモリアクセス: DataStoreService のリクエストは、非同期で動作するウェブリクエストを行い、レート制限の対象となります。これは、セッションの開始時に初期読み込みを行うのには適していますが、ゲームプレイ中の高頻度の読み書き操作には適していません。ほとんどの開発者のプレイヤーデータシステムは、このデータを Roblox サーバーのメモリ内に保存し、DataStoreService リクエストを以下のシナリオに制限しています:

    • セッションの開始時の初期読み込み
    • セッションの終了時の最終書き込み
    • 最終書き込みが失敗した場合に備えた定期的な書き込み
    • 購入処理中にデータが保存されることを確認するための書き込み
  • 効率的なストレージ: プレイヤーのセッションデータを単一のテーブルに保存すると、複数の値を原子的に更新でき、同じ量のデータをより少ないリクエストで処理できます。また、値間の非同期化のリスクを排除し、ロールバックをより簡単に処理できます。

    一部の開発者は、大きなデータ構造を圧縮するためにカスタムシリアル化も実装しています(通常、ゲーム内で生成されたコンテンツを保存するため)。

  • 複製: クライアントはプレイヤーのデータに定期的にアクセスする必要があります(例えば、UI を更新するため)。プレイヤーデータをクライアントに複製するための一般的なアプローチにより、各データコンポーネントのために特注の複製システムを作成することなく、この情報を送信できます。開発者は、クライアントに複製するデータとしないデータを選択的に指定するオプションが必要な場合があります。

  • エラーハンドリング: データストアにアクセスできない場合、多くのソリューションは再試行メカニズムと「デフォルト」データへのフォールバックを実装します。フォールバックデータが「本物」のデータを後で上書きしないように注意が必要であり、これはプレイヤーに適切に伝える必要があります。

  • 再試行: データストアがアクセスできない場合、多くのソリューションは再試行メカニズムとデフォルトデータへのフォールバックを実装します。フォールバックデータが「本物」のデータを後で上書きしないように十分に注意し、プレイヤーに状況を適切に伝える必要があります。

  • セッションロック: 単一のプレイヤーデータが複数のサーバーにロードされメモリ内に存在する場合、古い情報を保存するサーバーが発生する問題が生じる可能性があります。これによりデータ損失や一般的なアイテム重複の抜け穴が発生することがあります。

  • 原子的な購入処理: アイテムが失われたり重複して与えられないように、購入を確認、報酬し、記録します。

サンプルコード

Roblox には、プレイヤーデータシステムの設計と構築を支援するためのリファレンスコードがあります。このページの残りの部分では、背景、実装の詳細、および一般的な注意点について説明します。


モデルをスタジオにインポートすると、次のフォルダー構造が表示されます。

購入システムモデルを表示しているエクスプローラーウィンドウ。

アーキテクチャ

この高レベルの図は、サンプル内の主要なシステムと、それらがゲームの他の部分のコードとどのようにインターフェースするかを示しています。

コードサンプルのアーキテクチャダイアグラム。

再試行

クラス: DataStoreWrapper

背景

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 リクエストは無効な一時状態を避けるために順番に処理される必要があります(例えば、購入が処理される前にコインを減らし、その後コイン追加が処理されて負のコインになる)。

私たちのプレイヤーデータシステムは、キーごとに順番に処理されることが保証される、Yielding 再試行を提供する新しいクラス DataStoreWrapper を使用しています。

アプローチ

再試行システムを示すプロセスダイアグラム。

DataStoreWrapper は、DataStore メソッドに対応するメソッドを提供します:DataStore:GetAsync()DataStore:SetAsync()DataStore:UpdateAsync()、および DataStore:RemoveAsync()

これらのメソッドが呼び出されると:

  1. リクエストがキューに追加されます。各キーには独自のキューがあり、リクエストは順番に処理されます。リクエストスレッドは、リクエストが完了するまで待機します。

    この機能は、コルーチンベースのタスクスケジューラおよびレートリミッターである ThreadQueue クラスに基づいています。ThreadQueue はプロミスを返すのではなく、操作が完了するまで現在のスレッドを待機させ、失敗した場合にはエラーを発生させます。これは、慣用的な非同期の 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 メソッドを公開しています。詳細は プレイヤーデータ を参照してください。

セッションロック

クラス: SessionLockedDataStoreWrapper

背景

プレイヤーデータはサーバーのメモリに保存され、必要に応じて基盤となるデータストアからのみ読み書きされます。データストアの制限を超えずに、メモリ内のプレイヤーデータを即座に読み込みおよび更新できます。

このモデルが意図したとおりに機能するためには、同時に一つのサーバーのみがプレイヤーのデータを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に参加します。

これらのシナリオは稀であるべきですが、特にプレイヤーがサーバーAから迅速にサーバーBに接続する場合(例えば、テレポート中)に発生することがあります。一部の悪意のあるユーザーは、これを悪用してアクションを実行して持続させないことを試みるかもしれません。これは、プレイヤーがトレードを行うことを許可するゲームで特に影響があり、アイテム重複のエクスプロイトの一般的なソースです。

セッションロックはこの脆弱性に対処し、プレイヤーの DataStore キーが最初にサーバーによって読み込まれるときに、サーバーが同じ UpdateAsync() コール内でそのキーのメタデータ内にロックを書き込むことを保証します。このロックの値が他のサーバーがそのキーを読み書きしようとする際に存在する場合、そのサーバーは処理を続行しません。

アプローチ

セッションロックシステムを示すプロセスダイアグラム。

SessionLockedDataStoreWrapper は、 DataStoreWrapper クラスのメタラッパーです。DataStoreWrapper はキューイングおよび再試行機能を提供し、SessionLockedDataStoreWrapper はセッションロックを補完します。

SessionLockedDataStoreWrapper は、すべての DataStore リクエストを UpdateAsync に通過させます。UpdateAsync はキーを原子的に読み書きすることを許可します。また、変換コールバックで nil を返すことにより、読み込まれた値に基づいて書き込みを放棄することも可能です。

リクエストごとに渡される変換関数は以下の操作を実行します:

  1. キーに安全にアクセスできるか確認し、できない場合は操作を中止します。「安全にアクセスできる」ことは以下を意味します:

    • キーのメタデータオブジェクトに、最後の更新時刻がロックの有効期限よりも短い、認識できない LockId 値が含まれていないこと。これは、別のサーバーによって設定されたロックを尊重し、そのロックが期限切れの場合にはそれを無視することに当たります。

    • このサーバーが以前にキーのメタデータに自身の LockId 値を置いた場合、その値がまだキーのメタデータに存在します。これは、別のサーバーがこのサーバーのロックを引き継ぎ(期限切れまたは強制的に)その後に解放した場合の状況を考慮しています。別の言い方をすれば、LockIdnil であっても、別のサーバーがロックを置き換えた可能性があります。

  2. UpdateAsyncSessionLockedDataStoreWrapper の消費者によって要求された DataStore 操作を実行します。例えば、GetAsync()function(value) return value end に変換されます。

  3. リクエストに渡されるパラメータに応じて、UpdateAsync はキーをロックまたはロック解除します:

    1. キーをロックする場合、UpdateAsync はキーのメタデータ内に LockId を GUID に設定します。この GUID はサーバーのメモリ内に保存され、次にキーにアクセスする際に検証できます。このサーバーがこのキーにロックを保持している場合、何の変更も行いません。また、ロックの有効期限内に再度アクセスしない場合に警告するタスクもスケジュールします。

    2. キーのロックを解除する場合、UpdateAsync はキーのメタデータ内の LockId を削除します。

カスタム再試行ハンドラーが基盤となる DataStoreWrapper に渡され、セッションがロックされているためにステップ1で中止された場合に操作が再試行されます。

また、カスタムエラーメッセージも消費者に返されるため、プレイヤーデータシステムはクライアントに対してセッションロックの代わりに異なるエラーを報告できます。

注意点

セッションロック制度は、サーバーがキープに対してロックを解放することを常に信頼しています。これは、PlayerRemoving または BindToClose() の最終書き込みの一部として、ロック解除の指示を通じて常に行われるべきです。

しかし、いくつかの状況でロック解除が失敗する可能性があります。例えば:

  • サーバーがクラッシュしたり、DataStoreService がすべてのキーへのアクセスに対してサービス不能である。
  • 論理エラーや類似のバグにより、キーをロック解除する指示が行われなかった。

ロックを維持するためには、メモリ内で読み込まれている限り、定期的にそれにアクセスする必要があります。これは通常、ほとんどのプレイヤーデータシステムのバックグラウンドで実行されている自動保存ループの一部として行われますが、このシステムでは手動で行う必要がある場合のために refreshLockAsync メソッドを公開しています。

ロックの有効期限が過ぎてロックが更新されなかった場合、どのサーバーもロックを引き継ぐ自由があります。異なるサーバーがロックを引き継ぐと、現在のサーバーがキーを読み取ろうとしたり書き込もうとする試みは、新しいロックを確立しない限り失敗します。

デベロッパープロダクト処理

シングルトン: ReceiptHandler

背景

ProcessReceipt コールバックは、購入を確定させるタイミングを判断する重要な役割を果たします。ProcessReceipt は非常に特定のシナリオで呼ばれます。その保証の詳細については、MarketplaceService.ProcessReceipt を参照してください。

"Handling" 購入の定義はゲームによって異なる場合がありますが、以下の基準を使用します。

  1. 購入は以前に処理されていないこと。

  2. 購入は現在のセッションに反映されていること。

  3. 購入は DataStore に保存されていること。

    すべての購入、たとえ一時的な消費物であっても、ユーザーの購入履歴がセッションデータに含まれるように DataStore に反映されるべきです。

これには、PurchaseGranted を返す前に以下の操作が必要です:

  1. PurchaseId がすでに処理されたものとして記録されていないことを確認します。
  2. プレイヤーのメモリ内プレイヤーデータに購入を付与します。
  3. プレイヤーのメモリ内プレイヤーデータに PurchaseId を処理済みとして記録します。
  4. プレイヤーのメモリ内プレイヤーデータを DataStore に書き込みます。

セッションロックはこのフローを簡素化します。これにより、以下のシナリオについて心配する必要がなくなります。

  • 現在のサーバーのメモリ内プレイヤーデータが古くなる可能性があるため、PurchaseId 履歴を検証する前に DataStore から最新の値を取得する必要があること
  • 同じ購入に対するコールバックが別のサーバーで実行される可能性があり、両方で 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 を返します。

プレイヤーデータ

シングルトン: PlayerData.ServerPlayerData.Client

背景

プレイヤーのセッションデータを同期的に読み書きするためのインターフェースを提供するモジュールは、Roblox のゲームで一般的です。このセクションでは PlayerData.ServerPlayerData.Client について説明します。

アプローチ

PlayerData.ServerPlayerData.Client は以下のことを処理します:

  1. プレイヤーのデータをメモリに読み込み、読み込みに失敗した場合を処理します。
  2. サーバーコードがプレイヤーデータを照会および変更できるインターフェースを提供します。
  3. プレイヤーデータの変更をクライアントに複製し、クライアントコードがアクセスできるようにします。
  4. 読み込みや保存のエラーをクライアントに複製し、エラーダイアログを表示できるようにします。
  5. プレイヤーのデータを定期的に保存し、プレイヤーが離れたとき、およびサーバーがシャットダウンするときに保存します。

プレイヤーデータの読み込み

読み込みシステムを示すプロセスダイアグラム。
  1. SessionLockedDataStoreWrapper が データストアに対して getAsync リクエストを行います。

    このリクエストが失敗した場合、デフォルトデータが使用され、プロフィールは「エラー」とマークされ、後にデータストアに書き込まれないようにします。

    代替オプションはプレイヤーをキックすることですが、私たちはプレイヤーにデフォルトデータでプレイしてもらい、何が起こったかを明確に伝える方が、ゲームから削除するよりも良いことを推奨します。

  2. 読み込まれたデータとエラーステータス(あれば)を含む初期ペイロードが PlayerDataClient に送信されます。

  3. プレイヤーのデータを使用して waitForDataLoadAsync で待機していたスレッドが再開されます。

サーバーコードのインターフェースを提供する

  • PlayerDataServer はシングルトンであり、同じ環境で実行されている任意のサーバーコードによって要求され、アクセスできます。
  • プレイヤーデータはキーと値の辞書に整理されます。これらの値は、サーバーで setValuegetValueupdateValue および removeValue メソッドを使用して操作できます。これらのメソッドはすべて、待機せずに同期的に動作します。
  • データにアクセスする前にデータが読み込まれていることを確認するために、hasLoadedwaitForDataLoadAsync メソッドが利用可能です。これを行うことを、他のシステムが開始される前に読み込み画面中に1回行うことを推奨します。そうすると、クライアントでデータと相互作用するたびに読み込みエラーを確認する必要がなくなります。
  • プレイヤーの最初の読み込みが失敗した場合、デフォルトデータを使用することになった場合に確認するための hasErrored メソッドがあります。このメソッドを確認して、プレイヤーが購入できるかどうかを確認してください。成功した読み込みがなければ、購入はデータに保存できません。
  • プレイヤーのデータが変更されるたびに playerDataUpdated シグナルが playerkey、および value を持って発火します。個々のシステムがこれにサブスクライブできます。

クライアントへの変更の複製

  • PlayerDataServer でのプレイヤーデータの変更は、 PlayerDataClient に複製されます。ただし、そのキーが setValueAsPrivate を使用して非公開としてマークされていた場合は除きます。
    • setValueAsPrivate は、クライアントに送信すべきでないキーを示すために使用されます。
  • PlayerDataClient にはキーの値を取得するためのメソッド(get)と、更新されたときに発火するシグナル(updated)が含まれています。また、データが読み込まれるのを待ったり複製したりするクライアントのシステムが開始される前に、データが読み込まれるのを待てるように、 hasLoaded メソッドと loaded シグナルも含まれます。
  • PlayerDataClient はシングルトンであり、同じ環境で実行されている任意のクライアントコードによって要求され、アクセスできます。

エラーをクライアントに複製する

  • プレイヤーデータの保存または読み込み中に発生したエラー状況が PlayerDataClient に複製されます。
  • この情報には、 getLoadError および getSaveError メソッド、ならびに loaded および saved シグナルを使用してアクセスします。
  • エラーには、 DataStoreErrorDataStoreService リクエストが失敗した場合)と SessionLocked(セッションロックを参照)があります。
  • これらのイベントを使用して、クライアントの購入プロンプトを無効にし、警告ダイアログを実装します。この画像は、プレイヤーデータの読み込みが失敗した場合に表示される可能性のある警告の例を示しています。
プレイヤーデータの読み込みが失敗した場合に表示される可能性のある例の警告のスクリーンショット。

プレイヤーデータの保存

保存システムを示すプロセスダイアグラム。
  1. プレイヤーがゲームを離れると、システムは次の手順を実行します:

    1. プレイヤーのデータをデータストアに書き込むのが安全かどうかを確認します。安全でないシナリオには、プレイヤーのデータの読み込みが失敗したり、まだ読み込み中である場合が含まれます。
    2. SessionLockedDataStoreWrapper を介して、現在のメモリ内データ値をデータストアに書き込み、完了したらセッションロックを解除するリクエストを行います。
    3. プレイヤーのデータ(およびメタデータやエラーステータスなどの他の変数)をサーバーメモリからクリアします。
  2. 定期的なループで、サーバーは各プレイヤーのデータをデータストアに書き込みます(保存するのが安全な場合)。この受け入れられる冗長性は、サーバーがクラッシュした際のデータ損失を緩和し、セッションロックを維持するためにも必要です。

  3. サーバーのシャットダウンリクエストが受信されると、以下のことが BindToClose コールバック内で発生します:

    1. サーバー内の各プレイヤーデータを保存するリクエストが行われ、プレイヤーがサーバーを離れるときに通常通過するプロセスに従います。これらのリクエストは並行して作成され、BindToClose コールバックは完了するまで30秒しかかけることができません。
    2. 保存を迅速化するために、古いリクエストはすべて DataStoreWrapper のキューからクリアされます(再試行を参照)。
    3. コールバックは、すべてのリクエストが完了するまで返しません。
©2026 Roblox Corporation。Roblox(ロブロックス)、RobloxロゴおよびPowering Imaginationは、米国並びにその他の国における登録商標および非登録商標です。