Chương 10: Hướng dẫn Tích hợp End-to-End
Hướng dẫn thực hành từng bước tích hợp toàn diện các dịch vụ cốt lõi của MGW SDK vào một dự án game hoàn chỉnh trong namespace MyGame: từ thiết lập Startup, Remote Config typed binding, popup nhận thưởng nhân đôi qua Rewarded Ad, theo dõi Analytics, mua hàng IAP an toàn dữ liệu, đến chuyển màn hình kèm Interstitial Ad.
1. Kiến trúc luồng tích hợp & Mental Model
Trong một dự án game chuẩn, mã nguồn game không nên gọi SDK phân tán rải rác. Chúng ta sẽ xây dựng một cấu trúc 5 thành phần mạch lạc và khép kín:
- Cấu hình & Điểm khởi động (MyGameConfig & MyGameStartup): Đăng ký handler mua hàng IAP và liên kết dữ liệu Remote Config ngay khi scene đầu tiên khởi chạy.
- Hệ thống Kinh tế (EconomyManager): Class static quản lý số dư tiền ảo, grant handler IAP và luồng mua; đảm bảo dữ liệu luôn được ghi xuống đĩa qua
LocalStoragetrước khi xác nhận đơn hàng với Store. - Màn hình Thưởng (LevelRewardDialog): Kế thừa
DialogBasecủa SDK, đọc hệ số nhân từ Remote Config, hiển thị nút xem Rewarded Ad và cấp thưởng an toàn. - Theo dõi Analytics (AnalyticsHelper): Gom các lời gọi
App.Analytics(level, biến động coin) vào một chỗ để tránh rải rác khắp code game. - Điều phối Gameplay (GamePlayManager): Phát các sự kiện Analytics (bắt đầu, kết thúc level, biến động tiền tệ) và điều phối chuyển màn qua Interstitial Ad.
Bước 1: Khởi tạo Startup, Remote Config & IAP Handler
Trước hết, tạo một class định nghĩa các tham số cấu hình từ xa cho trò chơi. Cơ chế Bind của SDK sẽ tự động khớp tên các trường công khai (public instance fields) với các key trên máy chủ (Firebase Remote Config hoặc SayKit GameConfig):
using System;
namespace MyGame
{
// Public instance fields are bound by name to Remote Config keys (MGW) or to
// SayKitGameConfig fields (SayKit). The initial value is the offline default.
// Keep only the game's own keys here: SDK-owned keys such as inter_interval_sec and
// inter_first_delay_sec stay in CoreConfig.remoteConfigDefaults. Binding them here
// would overwrite the SDK default with this class's value.
[Serializable]
public sealed class MyGameConfig
{
public float coin_reward_multiplier = 2f;
public int starter_coins = 100;
}
}
Tiếp theo, tạo class khởi động MyGameStartup gắn trên một GameObject ở scene đầu tiên sau scene Loading (ví dụ Home) (hoặc gọi qua [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]):
using UnityEngine;
using TapBrain.Core;
namespace MyGame
{
public sealed class MyGameStartup : MonoBehaviour
{
public static MyGameConfig Config { get; private set; }
private void Awake()
{
// Services already exist here (Boot runs before the first scene). Config holds
// the defaults until Remote Config is fetched, then is refilled before Updated fires.
Config = App.RemoteConfig.Bind(new MyGameConfig());
App.RemoteConfig.Updated += OnRemoteConfigUpdated;
// Register the grant handler as early as possible: pending orders from a previous
// session are re-delivered as soon as it is set.
App.Iap.SetGrantHandler(EconomyManager.GrantProduct);
App.Iap.RemoveAdsGranted += OnRemoveAdsGranted;
Debug.Log($"[MyGame] Startup completed. Multiplier={Config.coin_reward_multiplier}");
}
private void OnDestroy()
{
App.RemoteConfig.Updated -= OnRemoteConfigUpdated;
App.Iap.RemoveAdsGranted -= OnRemoveAdsGranted;
}
private void OnRemoteConfigUpdated()
{
Debug.Log($"[MyGame] Remote Config updated. New multiplier={Config.coin_reward_multiplier}");
}
private void OnRemoveAdsGranted()
{
Debug.Log("[MyGame] Remove Ads unlocked.");
}
}
}
Dự án chỉ được phép đăng ký duy nhất một Grant Handler qua App.Iap.SetGrantHandler. Ngay khi hàm này được gọi, nếu trong bộ nhớ thiết bị còn lưu các đơn hàng IAP chưa được hoàn tất ở các phiên chơi trước, SDK sẽ tự động chuyển tiếp chúng vào handler này để tránh làm mất giao dịch của người chơi.
Bước 2: Màn hình kết quả DialogBase & Rewarded Ad x2 Thưởng
Mọi popup trong dự án nên kế thừa lớp trừu tượng DialogBase của SDK. Prefab của popup phải được đặt trong thư mục Assets/Resources/Dialogs/LevelRewardDialog.prefab và có component CanvasGroup đi kèm:
using System;
using UnityEngine;
using UnityEngine.UI;
using TapBrain.Core;
namespace MyGame
{
// Prefab: Assets/Resources/Dialogs/LevelRewardDialog.prefab (folder and file name = type name).
// Untick "Close On Back" on the prefab, see the note below the code.
public sealed class LevelRewardDialog : DialogBase
{
[Header("UI References")]
[SerializeField] private Text rewardText;
[SerializeField] private Button watchAdButton;
[SerializeField] private Button claimButton;
private int _baseCoins;
private bool _isAdRewarded;
private Action _onProceed;
protected override void Awake()
{
base.Awake();
if (watchAdButton != null) watchAdButton.onClick.AddListener(OnWatchAdClicked);
if (claimButton != null) claimButton.onClick.AddListener(OnClaimClicked);
}
public void Setup(int earnedCoins, Action onProceed)
{
_baseCoins = earnedCoins;
_onProceed = onProceed;
_isAdRewarded = false;
UpdateUI();
}
private void UpdateUI()
{
int displayAmount = _isAdRewarded ? Mathf.RoundToInt(_baseCoins * Multiplier()) : _baseCoins;
if (rewardText != null) rewardText.text = $"+{displayAmount} COINS";
// Hide the ad button once the bonus has been granted.
if (watchAdButton != null) watchAdButton.gameObject.SetActive(!_isAdRewarded);
}
private static float Multiplier()
{
return MyGameStartup.Config != null ? MyGameStartup.Config.coin_reward_multiplier : 2f;
}
private void OnWatchAdClicked()
{
if (!App.Ads.IsRewardedReady)
{
App.Overlay.Toast("Quảng cáo chưa sẵn sàng, vui lòng thử lại sau!");
return;
}
// Prevent double taps while the ad is showing.
if (watchAdButton != null) watchAdButton.interactable = false;
// onResult is invoked exactly once. The dialog may be destroyed before it arrives,
// so touch UI only through null-checked references.
App.Ads.ShowRewarded("reward_x2_coins", result =>
{
if (watchAdButton != null) watchAdButton.interactable = true;
switch (result)
{
case RewardedResult.Rewarded:
// Grant only here. The base coins are paid on claim, so pay the extra part now.
_isAdRewarded = true;
int bonus = Mathf.RoundToInt(_baseCoins * (Multiplier() - 1f));
EconomyManager.AddCoins(bonus, "reward_x2_ad");
UpdateUI();
App.Overlay.Toast("Đã nhân đôi phần thưởng thành công!");
break;
case RewardedResult.Skipped:
App.Overlay.Toast("Bạn đã bỏ qua quảng cáo, chưa nhận được thưởng!");
break;
case RewardedResult.NotReady:
App.Overlay.Toast("Quảng cáo chưa tải xong!");
break;
case RewardedResult.Failed:
App.Overlay.Toast("Không thể phát quảng cáo vào lúc này.");
break;
}
});
}
private void OnClaimClicked()
{
EconomyManager.AddCoins(_baseCoins, "level_end_claim");
// Close() plays the close animation, then destroys the dialog.
Close();
_onProceed?.Invoke();
}
protected override void OnClosed()
{
base.OnClosed();
Debug.Log("[MyGame] LevelRewardDialog fully closed.");
}
}
}
Tuyệt đối không cấp thưởng trong các trường hợp Skipped, NotReady, hoặc Failed. Callback onResult luôn được SDK đảm bảo gọi chính xác một lần duy nhất.
DialogBase mặc định đóng popup khi người chơi bấm nút Back của Android (closeOnBack = true). Với popup này, Back sẽ
Close() mà không đi qua OnClaimClicked: người chơi mất phần coin cơ bản và onProceed không bao giờ chạy nên game
đứng ở màn kết thúc. Bỏ chọn ô Close On Back trên component LevelRewardDialog trong prefab để chỉ nút Claim mới đóng được popup.
Bước 3: Tích hợp Analytics Theo dõi Level & Biến động Tiền tệ
Sử dụng facade App.Analytics để gửi các sự kiện chuẩn hóa. Từ vựng sự kiện này hoạt động tương thích hoàn hảo trên cả MGW (Firebase/GameAnalytics) và SayKit:
using UnityEngine;
using TapBrain.Core;
using TapBrain.Core.Services;
namespace MyGame
{
public static class AnalyticsHelper
{
public static void TrackLevelStart(int levelNumber)
{
App.Analytics.LevelStarted(levelNumber, tag: "campaign");
Debug.Log($"[MyGame] Analytics -> LevelStarted({levelNumber})");
}
public static void TrackLevelComplete(int levelNumber, long score)
{
// The SDK measures duration_sec between LevelStarted and LevelCompleted by itself.
App.Analytics.LevelCompleted(levelNumber, score, tag: "campaign");
Debug.Log($"[MyGame] Analytics -> LevelCompleted({levelNumber}, score={score})");
}
public static void TrackLevelFailed(int levelNumber, long score)
{
App.Analytics.LevelFailed(levelNumber, score, tag: "campaign");
}
public static void TrackCoinIncome(int amount, int newTotal, string sourcePlace)
{
App.Analytics.CurrencyIncome(CurrencyType.Soft, amount, newTotal, place: sourcePlace);
Debug.Log($"[MyGame] Analytics -> CurrencyIncome(+{amount}, total={newTotal}, place={sourcePlace})");
}
public static void TrackCoinExpense(int amount, int newTotal, string itemPlace)
{
App.Analytics.CurrencyOutcome(CurrencyType.Soft, amount, newTotal, place: itemPlace);
Debug.Log($"[MyGame] Analytics -> CurrencyOutcome(-{amount}, total={newTotal}, place={itemPlace})");
}
}
}
Bước 4: Mua hàng IAP & Đảm bảo Lưu trữ qua LocalStorage
Hệ thống lưu trữ LocalStorage của SDK là lớp bọc an toàn kiểu dữ liệu trên nền PlayerPrefs. Toàn bộ key của SDK Core dùng tiền tố core., do đó mã nguồn của game bắt buộc phải sử dụng tiền tố riêng biệt dạng game. để không bao giờ xảy ra xung đột:
using System;
using UnityEngine;
using TapBrain.Core;
namespace MyGame
{
// Plain static class: no singleton instance, state lives in LocalStorage.
public static class EconomyManager
{
// Game keys use the "game." prefix; "core." is reserved for the SDK.
private const string CoinsKey = "game.coins";
// Product ids as declared in the store and in the IAP Catalog.
public const string ProductCoinsPack100 = "com.mycompany.mygame.coins100";
private static bool _purchaseInFlight;
public static int Coins => LocalStorage.GetInt(CoinsKey, 0);
// Registered with App.Iap.SetGrantHandler. Return true only after the content is
// delivered AND saved. false (or an exception) keeps the order pending and it is
// re-delivered on the next launch.
// The remove-ads product (CoreConfig.removeAdsProductId) is granted by Core itself and
// never reaches this handler: use App.Iap.IsOwned(removeAdsProductId) to read its state.
public static bool GrantProduct(string productId)
{
Debug.Log($"[MyGame] Processing grant for product: {productId}");
if (productId != ProductCoinsPack100)
{
Debug.LogWarning($"[MyGame] Unrecognized productId '{productId}', kept pending.");
return false;
}
AddCoins(100, "iap_pack_100"); // saves to disk before returning
Debug.Log($"[MyGame] Granted 100 coins! New balance: {Coins}");
return true;
}
public static void AddCoins(int amount, string place)
{
int newTotal = Coins + amount;
LocalStorage.SetInt(CoinsKey, newTotal);
LocalStorage.Save(); // write to disk now, do not wait for the next autosave
AnalyticsHelper.TrackCoinIncome(amount, newTotal, place);
}
// Called from the shop UI. The Purchase callback is UI feedback only: never grant here.
public static void BuyProduct(string productId, Action<bool> onComplete)
{
// The SDK ignores a second Purchase of a product that is in flight and never calls
// its callback, so a ref-counted blocker shown for it would never be hidden.
if (_purchaseInFlight) return;
if (!App.Iap.IsReady)
{
App.Overlay.Toast("Cửa hàng chưa sẵn sàng, vui lòng đợi!");
onComplete?.Invoke(false);
return;
}
_purchaseInFlight = true;
App.Overlay.ShowBlocker();
App.Iap.Purchase(productId, result =>
{
_purchaseInFlight = false;
App.Overlay.HideBlocker();
switch (result)
{
case PurchaseResult.Success:
App.Overlay.Toast("Giao dịch thành công!");
onComplete?.Invoke(true);
break;
case PurchaseResult.Cancelled:
App.Overlay.Toast("Bạn đã hủy giao dịch.");
onComplete?.Invoke(false);
break;
case PurchaseResult.Pending:
App.Overlay.Toast("Giao dịch đang chờ xử lý...");
onComplete?.Invoke(false);
break;
default: // Failed, NotReady
App.Overlay.Toast("Giao dịch thất bại!");
onComplete?.Invoke(false);
break;
}
});
}
}
}
Hàm PurchaseGrantHandler chỉ được phép trả về true sau khi nội dung đã được giao và lưu kiên cố xuống bộ nhớ lưu trữ vật lý (LocalStorage.Save()). Nếu xảy ra ngoại lệ hoặc hàm trả về false, đơn hàng sẽ được giữ ở trạng thái Pending cả trên thiết bị lẫn Store để SDK gửi lại trong lần mở app tiếp theo.
Sản phẩm khai báo ở CoreConfig.removeAdsProductId được SDK tự cấp (đặt cờ core.remove_ads, chặn banner/interstitial/app-open và bắn
RemoveAdsGranted). Handler của game không bao giờ nhận productId này, nên đừng tạo thêm cờ riêng; đọc trạng thái bằng
App.Iap.IsOwned(removeAdsProductId). Ngoài ra, SDK bỏ qua lần Purchase thứ hai của cùng sản phẩm đang xử lý và không gọi callback của
lần đó; vì ShowBlocker được đếm tham chiếu, ví dụ trên dùng cờ _purchaseInFlight để mỗi ShowBlocker luôn có đúng một HideBlocker.
Bước 5: Chuyển màn chơi kèm Interstitial Ad qua App.Scenes.Load
Trong vòng lặp game, khi người chơi hoàn thành một màn chơi và chuyển sang màn chơi mới, việc hiển thị quảng cáo xen kẽ (Interstitial) cần được phối hợp nhịp nhàng với bộ tải Scene App.Scenes.Load:
using UnityEngine;
using TapBrain.Core;
namespace MyGame
{
public sealed class GamePlayManager : MonoBehaviour
{
[SerializeField] private int currentLevel = 1;
[SerializeField] private int nextSceneBuildIndex = 2; // index in Build Settings
private void Start()
{
AnalyticsHelper.TrackLevelStart(currentLevel);
}
// Called by the game when the player wins.
public void OnLevelVictory(long finalScore)
{
AnalyticsHelper.TrackLevelComplete(currentLevel, finalScore);
// Show returns the open instance if there is one, or null when the prefab is missing.
var dialog = App.Dialogs.Show<LevelRewardDialog>();
if (dialog != null)
{
dialog.Setup(earnedCoins: 50, onProceed: HandleTransitionToNextLevel);
}
else
{
// Never block progression because a popup failed to load.
HandleTransitionToNextLevel();
}
}
private void HandleTransitionToNextLevel()
{
Debug.Log("[MyGame] Transitioning to next level with Interstitial Ad...");
// onClosed runs exactly once: when the ad closes, or synchronously before the call
// returns false if the ad was skipped (interval, remove ads, not ready, ...).
App.Ads.TryShowInterstitial("level_transition", () =>
{
Debug.Log($"[MyGame] Loading next scene index: {nextSceneBuildIndex}");
App.Scenes.Load(nextSceneBuildIndex, fade: true);
});
}
}
}
Nhờ hợp đồng gọi onClosed một lần duy nhất kể cả khi quảng cáo không hiển thị, bạn hoàn toàn có thể yên tâm lồng lệnh App.Scenes.Load bên trong callback mà không sợ màn hình game bị đứng hay bị treo vĩnh viễn.
Bước 6: Kiểm tra & Xác thực qua Console Log
Khi chạy thử nghiệm kịch bản trên trong Unity Editor, bạn sẽ quan sát thấy chuỗi log chuẩn mực sau trên cửa sổ Console:
1. Chuỗi Log khởi động & sẵn sàng dịch vụ
[Boot] RemoteConfig done in 0.08s
[Boot] Ads done in 0.15s
[Ads] MAX initialized
[Boot] IAP done in 0.20s
[IAP] Ready, 2 products
[Boot] Completed, user=usr_8a92f0 new=False session=3
[MyGame] Startup completed. Multiplier=2
2. Khi chưa có Key hoặc chưa liên kết Unity Gaming Services
Trong môi trường Editor phát triển ban đầu, nếu dự án chưa điền đầy đủ key hoặc chưa link UGS, SDK vẫn hoạt động bền bỉ và in các dòng log thông tin sau:
[IAP] Unity Gaming Services init failed (project not linked?), continuing without it: ...
[IAP] No IReceiptValidator registered: receipts accepted without local validation
[Ads] MAX SDK key not set in CoreConfig, ads disabled
3. Chuỗi Log khi xem Rewarded Ad & Nhận thưởng
[Analytics] earn_virtual_currency
[MyGame] Granted 50 coins! New balance: 150
SDK không in log riêng khi quảng cáo thưởng mở hoặc đóng. Dòng [Analytics] earn_virtual_currency chỉ xuất hiện khi bạn gọi
App.Analytics.CurrencyIncome(...) và chỉ in tên sự kiện chung (không kèm tham số). Log mức Info chỉ hiện khi bật
verboseLog hoặc trong Development Build/Editor. Nếu quảng cáo không sẵn sàng, callback nhận RewardedResult.NotReady và game
tự ghi log của mình (như dòng [MyGame] ở trên).
4. Chuỗi Log khi chuyển màn chơi (Interstitial Ad Policy)
Nếu khoảng cách giữa 2 lần hiển thị quảng cáo toàn màn hình chưa đủ thời gian thiết lập trong inter_interval_sec (mặc định 30 giây), chính sách AdsPolicy sẽ tự động bỏ qua quảng cáo để tối ưu trải nghiệm người chơi:
[Ads] Inter skipped at level_transition: interval
[MyGame] Loading next scene index: 2
level_transition là chuỗi placement do game truyền vào, còn lý do bỏ qua có thể là remove_ads, first_delay,
interval, showing hoặc not_ready. onClosed vẫn được gọi đồng bộ trước khi hàm trả về false,
nên việc chuyển scene trong callback luôn chạy. App.Scenes.Load không in log khi tải thành công, chỉ cảnh báo khi bị bỏ qua
([Scene] Ignored load N: another load is running) hoặc lỗi khi index không có trong Build Settings.
5. Chuỗi Log khi mua gói IAP trong Shop
[MyGame] Processing grant for product: com.mycompany.mygame.coins100
[Analytics] earn_virtual_currency
[MyGame] Granted 100 coins! New balance: 250
[IAP] Confirmed tx=<transaction id>
[MyGame] Purchase callback: Success
SDK không in dòng "Purchase started". Thứ tự quan trọng: grant handler của game chạy và lưu dữ liệu xong (trả về true) trước khi SDK
xác nhận đơn ([IAP] Confirmed tx=...); callback của Purchase chỉ dùng để cập nhật UI. Khi mua lần thứ hai trong lúc một đơn
đang chạy, SDK in [IAP] Purchase of <id> already in progress và bỏ qua yêu cầu.
7. Tổng hợp file & thiết lập prefab
Mỗi khối code ở các bước trên là một file hoàn chỉnh, chép nguyên vào dự án (không có bản code thứ hai để bị lệch):
| File | Vai trò | Bước |
|---|---|---|
MyGameConfig.cs, MyGameStartup.cs | Bind Remote Config, đăng ký grant handler | 1 |
LevelRewardDialog.cs + prefab Assets/Resources/Dialogs/LevelRewardDialog.prefab | Popup thưởng, nút x2 qua Rewarded Ad | 2 |
AnalyticsHelper.cs | Gom các lời gọi App.Analytics | 3 |
EconomyManager.cs | Số dư coin, grant handler IAP, luồng mua | 4 |
GamePlayManager.cs | Hết màn: Analytics, popup, Interstitial, chuyển scene | 5 |
Prefab popup cần: component LevelRewardDialog (tự kèm CanvasGroup), gán rewardText, watchAdButton,
claimButton, và bỏ chọn Close On Back (lý do ở cuối Bước 2). Scene chứa MyGameStartup phải nằm sau scene Loading
(index 0) trong Build Settings.
8. Bảng đối chiếu Hợp đồng Tích hợp (Contract Checklist)
App.RemoteConfig.Bind và App.Iap.SetGrantHandler ở giai đoạn khởi động sớm, không giả định dịch vụ sẵn sàng ngay mà kiểm tra IsReady / sự kiện tương ứng.RewardedResult.Rewarded; xử lý đầy đủ các nhánh Skipped, NotReady, và Failed.true sau khi dữ liệu đã được lưu trữ an toàn bằng LocalStorage.Save(); không bao giờ cấp phát vật phẩm trong callback của Purchase.App.Scenes.Load vào trong callback onClosed của TryShowInterstitial để đảm bảo game luôn chuyển màn mượt mà.game., hoàn toàn tách biệt với tiền tố core. của SDK.