TapBrain Template

Chương 12: Dialog, Popup Queue & LiveOps

🎯 Quản trị trải nghiệm người chơi & Giữ chân (Retention)

Một game mobile prototype dù gameplay hấp dẫn đến đâu cũng sẽ thất bại trong đợt test thị trường (CPI / Retention test) nếu thiếu đi hai thành tố cốt lõi: Hệ thống quản lý Popup thông minh (không tranh chấp, không đè lấn, xếp hàng theo ưu tiên) và Hệ thống LiveOps giữ chân người chơi (quà đăng nhập, chuỗi ngày chơi, nhiệm vụ ngày, sự kiện giới hạn thời gian). Chương này sẽ hướng dẫn bạn tường tận kiến trúc, cách vận hành và mở rộng hai hệ thống này trong TapBrain Template.

Phần A: Hệ thống Dialog & Popup Queue

1. Vấn đề của chuỗi if/else hiển thị Popup trên Home

Trong hầu hết các dự án nghiệp dư hoặc code vội, developer thường viết một chuỗi if / else if khổng lồ trong hàm Start() của màn hình Home để quyết định popup nào được hiện lên khi người chơi vừa vào game:

// BAD PRACTICE: Chuỗi if/else tai hại thường gặp
void Start()
{
    if (IsFirstTimeUser()) ShowWelcomePopup();
    else if (HasDailyReward()) ShowDailyRewardPopup();
    else if (HasActiveEvent()) ShowEventPopup();
    else if (ShouldRateGame()) ShowRateUsPopup();
}

Cách tiếp cận ngây thơ này nhanh chóng dẫn đến 4 vấn đề nghiêm trọng khi phát triển game thực tế:

  • Tranh chấp thứ tự & Nuốt popup (Starvation): Nếu hôm nay có quà đăng nhập, người chơi sẽ không bao giờ thấy thông báo sự kiện mới hoặc lời mời đánh giá (Rate Us), vì nhánh else if phía sau đã bị bỏ qua vĩnh viễn trong phiên đó.
  • Hai popup hiện đè cùng lúc (Overlapping): Khi các hệ thống khác nhau (ví dụ: thông báo IAP trả về, thông báo rớt mạng, phần thưởng sự kiện) tự ý kích hoạt popup độc lập, 2 hoặc 3 popup sẽ hiện đè lên nhau, nền đen (overlay) bị nhân đôi độ đậm, và các nút bấm bị loạn raycast.
  • Popup hiện lén sau quảng cáo toàn màn hình (Ad Blocking): Nếu một quảng cáo Interstitial hoặc App Open xuất hiện cùng lúc với popup, popup sẽ nằm ngầm dưới màn hình quảng cáo. Người chơi không nhìn thấy, nhưng khi đóng quảng cáo lại chạm nhầm vào nút của popup.
  • Popup rò rỉ sang Scene tiếp theo (Scene Leaking): Khi người chơi nhanh tay bấm nút "PLAY" để vào màn chơi (Main scene), các lệnh Coroutine hoặc callback bất đồng bộ hẹn giờ mở popup ở Home vẫn chạy và bung popup ngay giữa lúc đang chơi game!
💡 Giải pháp của Template

Tách rời Cơ chế hiển thị (do DialogService quản lý tập trung) và Chiến lược xếp hàng (do DialogQueue sở hữu theo từng Scene kết hợp với mẫu thiết kế Strategy IPopupRule). Mọi popup đều là một yêu cầu gửi vào hàng đợi ưu tiên và được tiêu thụ tuần tự từng cái một.


2. Vòng đời DialogBase & Bản hợp đồng (Contract) Callback

Mọi popup trong template đều bắt buộc kế thừa từ lớp trừu tượng DialogBase (nằm trong namespace TapBrain.Core). Lớp này yêu cầu component CanvasGroup và quản lý hiệu ứng mở/đóng mượt mà qua coroutine Animate().

A. Vòng đời chi tiết của DialogBase

Giai đoạn Phương thức / Sự kiện Hành vi bên trong
Khởi tạo Awake() Tự động lấy component CanvasGroup để điều khiển alpha và chặn raycast.
Kích hoạt Open() (internal) Bật gameObject.SetActive(true), gọi hook OnOpened(), sau đó chạy coroutine Animate(0f, 1f, null).
Hiệu ứng Mở Animate(0f, 1f) Trong 0.15s (unscaled time), alpha tăng từ 0 → 1 và panel content phóng to từ 0.9 → 1.0. Trong lúc bay, interactable = false. Khi kết thúc, interactable = true.
Yêu cầu Đóng Close() (public) Kiểm tra if (IsClosing) return; để chống gọi đúp. Đặt IsClosing = true, chạy Animate(1f, 0f, callback).
Kết thúc Callback sau khi thu nhỏ Gọi OnClosed() → bắn sự kiện Closed?.Invoke(this) → gọi Destroy(gameObject).

B. Bản hợp đồng (Contract) Callback: Chỉ gọi đúng một lần từ OnClosed()

⚠️ QUY TẮC BẮT BUỘC (CONTRACT)

Callback trả về kết quả tương tác của Dialog (ví dụ: onResult, onChoice) BẮT BUỘC PHẢI ĐƯỢC GỌI TỪ OnClosed(), tuyệt đối không được gọi trực tiếp trong hàm lắng nghe sự kiện nút bấm (Button Click)!

Tại sao lại có quy tắc này? Một dialog có thể bị đóng bởi rất nhiều nguyên nhân ngoài việc người chơi bấm nút xác nhận:

  1. Người chơi bấm nút cứng Back trên điện thoại Android (bắt bởi CoreHost.BackPressed).
  2. Hệ thống gọi DialogService.CloseAll() do sự cố mất mạng hoặc bắt buộc cập nhật.
  3. Một dialog khẩn cấp khác mở lên với chế độ ReplaceTop hoặc ReplaceAll.

Nếu bạn gọi callback trong nút bấm, khi dialog bị đóng bởi phím Back hay CloseAll, callback sẽ không bao giờ được kích hoạt, khiến luồng game (Game Flow) bị treo vĩnh viễn (ví dụ: không bao giờ mở tiếp màn chơi sau kết quả game). Ngược lại, đặt trong OnClosed() đảm bảo dù đóng bằng bất kỳ cách nào, callback cũng được gọi đúng một lần duy nhất.

C. MessageDialog: Khuôn mẫu chuẩn mực

Hãy xem cách MessageDialog.cs cài đặt chuẩn mực bản hợp đồng này:

// Assets/_Project/Game/UI/Dialogs/MessageDialog.cs
public sealed class MessageDialog : DialogBase
{
    [SerializeField] private TextMeshProUGUI titleText;
    [SerializeField] private TextMeshProUGUI messageText;
    [SerializeField] private Button okButton;
    [SerializeField] private Button cancelButton;

    private Action<bool> _onResult;
    private bool _result;

    public MessageDialog Setup(string title, string message, string okText = "OK", 
        string cancelText = null, Action<bool> onResult = null)
    {
        _onResult = onResult;
        if (titleText != null) titleText.text = title;
        if (messageText != null) messageText.text = message;
        if (cancelButton != null) cancelButton.gameObject.SetActive(cancelText != null);
        return this;
    }

    protected override void OnOpened()
    {
        if (okButton != null) okButton.onClick.AddListener(OnOk);
        if (cancelButton != null) cancelButton.onClick.AddListener(OnCancel);
    }

    protected override void OnClosed()
    {
        // Gỡ bỏ listener để tránh rò rỉ bộ nhớ
        if (okButton != null) okButton.onClick.RemoveListener(OnOk);
        if (cancelButton != null) cancelButton.onClick.RemoveListener(OnCancel);

        // CONTRACT: Callback được gọi tại đây, đảm bảo Back/CloseAll/ReplaceAll đều nhận kết quả!
        var callback = _onResult;
        _onResult = null;
        callback?.Invoke(_result);
    }

    private void OnOk()
    {
        _result = true;
        Close(); // Chỉ kích hoạt animation đóng!
    }

    private void OnCancel()
    {
        _result = false;
        Close(); // Chỉ kích hoạt animation đóng!
    }
}

3. DialogService: Quản lý tập trung & Chế độ hiển thị

DialogService (nằm trong Core, truy cập qua App.Dialogs) tạo và giữ một Canvas riêng mang tên [Dialogs] có DontDestroyOnLoad với sortingOrder = 1000, đảm bảo mọi popup luôn hiển thị trên UI thông thường của Scene.

A. Các chế độ DialogShowMode

Chế độ (DialogShowMode) Hành vi Trường hợp sử dụng thực tế Ví dụ code
Stack (Mặc định) Mở đè lên trên cùng của ngăn xếp; các dialog phía dưới vẫn giữ nguyên trạng thái mở. Người chơi bấm nút mở Shop, Settings, hoặc hiện popup xác nhận hỏi "Bạn có chắc chắn muốn mua?". App.Dialogs.Show<ShopDialog>();
ReplaceTop Đóng dialog ở đỉnh ngăn xếp hiện tại, sau đó mở dialog mới này ra. Các luồng gồm nhiều bước (Step 1 → Step 2), ví dụ: Hướng dẫn chơi bước 1 sang bước 2. App.Dialogs.Show<StepTwoDialog>(DialogShowMode.ReplaceTop);
ReplaceAll Đóng toàn bộ tất cả dialog đang mở trên màn hình, sau đó mở duy nhất dialog này. Sự kiện khẩn cấp hệ thống: Bắt buộc cập nhật phiên bản mới, mất kết nối mạng máy chủ, bảo trì. App.Dialogs.Show<ForceUpdateDialog>(DialogShowMode.ReplaceAll);

B. Nguyên tắc Mỗi Type chỉ có 1 Instance & Tự dọn dẹp khi đổi Scene

  • Chống spam nút bấm: Trong Show<T>(), DialogService luôn kiểm tra Find<T>(). Nếu một dialog cùng loại T đang mở và chưa trong quá trình đóng (!IsClosing), hệ thống sẽ trả về ngay chính instance đó thay vì sinh thêm một bản sao. Do đó người chơi ấn liên tục 5 lần vào nút "Settings" cũng chỉ có 1 popup xuất hiện.
  • Cache Prefab: Prefab được load từ Resources/Dialogs/<TypeName> và lưu trong từ điển Dictionary<Type, DialogBase>, không tốn chi phí Resources.Load lặp lại.
  • Tự đóng khi chuyển Scene: Dù Canvas [Dialogs] sống xuyên Scene, mọi popup đang mở đều được đóng sạch sẽ trước khi Scene mới tải xong, tránh tình trạng popup của Home tồn đọng sang Main.
  • Sự kiện toàn cục: Cung cấp DialogOpened và DialogClosed để các hệ thống ngoài (như LiveOpsHud) lắng nghe và cập nhật lại giao diện.

4. DialogQueue: Hàng đợi Popup ưu tiên theo Scene

Để giải quyết dứt điểm vấn đề của chuỗi if/else, template cung cấp DialogQueue. Mỗi Scene sẽ tự tạo và sở hữu một instance DialogQueue riêng trong hàm Start() và hủy trong OnDestroy():

// Chuẩn khởi tạo hàng đợi popup trong HomeScreen.cs
private DialogQueue _popups;

private void Start()
{
    _popups = App.Dialogs.CreateQueue();
    HomePopups.EnqueueAll(_popups);
}

private void OnDestroy() => _popups?.Dispose();

A. Quy tắc hoạt động của DialogQueue

  • Cú pháp Enqueue: queue.Enqueue<T>(priority, setup, canShow, id);
  • Sắp xếp ưu tiên (Priority Queue): Popup có priority lớn hơn sẽ được lấy ra hiển thị trước. Nếu hai popup có cùng priority, cái nào được đưa vào hàng đợi trước (FIFO theo biến đếm _nextOrder) sẽ được hiện trước.
  • Gom nhóm cùng Frame: Các lời gọi Enqueue diễn ra trong cùng một frame sẽ được tích lũy lại, coroutine DrainNextFrame() chờ sang frame tiếp theo (yield return null) mới bắt đầu sắp xếp và bung popup. Vì vậy, thứ tự gọi Enqueue trong code không làm sai lệch mức độ ưu tiên!
  • Điều kiện canShow kiểm tra muộn (Deferred Check): Hàm delegate canShow được đánh giá lại ngay trước thời điểm popup chuẩn bị bung lên. Nếu người chơi vừa nhận quà ở một luồng khác khiến điều kiện không còn thỏa mãn, request đó tự động bị bỏ qua.
  • Chống trùng bằng ID: Tham số id tùy chọn (mặc định lấy tên class typeof(T).Name) ngăn chặn việc đưa 2 popup trùng lặp vào hàng đợi cùng lúc.
  • Chờ khi có vật cản: Hàng đợi sẽ tạm dừng bung popup nếu:
    • Đang có một dialog bất kỳ trên màn hình (kể cả dialog mở thủ công ngoài queue).
    • Đang có quảng cáo toàn màn hình (Interstitial / Rewarded / App Open) đang chiếu (_adShowing == true).
    • Hàng đợi đang bị gọi Pause().
  • Cơ chế đếm tạm dừng (Ref-counted Pause/Resume): Gọi queue.Pause() sẽ tăng _pauseCount. Mỗi lần Pause() cần một lần Resume() tương ứng để trừ dần. Rất hữu ích khi cần chạy chuỗi Tutorial hoạt cảnh trước khi cho phép popup hiện lên.
  • Callback WhenIdle: Đăng ký hàm thực thi khi hàng đợi đã trống và không còn bất kỳ popup nào trên màn hình (IsIdle == true). Màn chơi Main sử dụng hàm này để chỉ mở ván tiếp theo sau khi người chơi đã đóng hết popup kết quả và các popup thưởng liên quan.
  • Tự hủy an toàn: Lắng nghe SceneLoader.SceneLoading để tự động gọi Dispose(), xóa sạch danh sách chờ và hủy các callback WhenIdle, loại bỏ hoàn toàn nguy cơ rò rỉ sang Scene mới.

B. Sơ đồ quyết định Drain của DialogQueue

Dưới đây là lưu đồ thể hiện thuật toán tiêu thụ hàng đợi trong DialogQueue.Drain():

flowchart TD
    Start["Kích hoạt ScheduleDrain()"] --> CheckDisposed{"Queue đã Dispose?"}
    CheckDisposed -->|"Có"| Stop["Dừng: Bỏ qua tác vụ"]
    CheckDisposed -->|"Không"| CheckDrain{"Đã lên lịch drain?"}
    CheckDrain -->|"Đã lên lịch"| Stop
    CheckDrain -->|"Chưa"| WaitFrame["Chờ hết frame (yield null)"]
    WaitFrame --> CheckBusy{"Đang bận hiển thị?"}
    CheckBusy -->|"Có Dialog/Ad/Pause"| Stop
    CheckBusy -->|"Sẵn sàng"| HasPending{"Còn popup chờ không?"}
    HasPending -->|"Không"| FireIdle["Kích hoạt WhenIdle()"]
    HasPending -->|"Có"| TakeBest["Lấy popup ưu tiên nhất"]
    TakeBest --> CheckCanShow{"CanShow() trả về true?"}
    CheckCanShow -->|"False"| HasPending
    CheckCanShow -->|"True"| OpenDialog["Mở DialogService.Show<T>()"]
    OpenDialog --> WaitClose["Chờ popup hiện tại đóng"]
    WaitClose -->|"Popup đã đóng"| Start
          

Các mức ưu tiên được quy định tập trung tại Assets/_Project/Game/Popups/PopupPriority.cs. Không bao giờ hard-code số nguyên rải rác trong code:

Hằng số (PopupPriority) Giá trị Mục đích sử dụng
Critical 1000 Thông báo khẩn: Mất kết nối, bắt buộc cập nhật phiên bản, máy chủ bảo trì.
LevelResult 900 Popup Thắng / Thua sau khi hoàn thành ván chơi trong scene Main.
Reward 700 Quà tặng đăng nhập hằng ngày, quà chuỗi ngày chơi (Streak), quà mốc sự kiện.
Welcome 600 Popup chào mừng tân thủ trong lần đầu tiên mở ứng dụng.
Feature 500 Mở khóa tính năng mới (ví dụ: mở chế độ chơi mới ở Level 10), giới thiệu sự kiện.
Offer 300 Gói ưu đãi nạp tiền, khuyến mãi Shop, gói giảm giá Starter Pack.
RateUs 100 Hỏi đánh giá ứng dụng (luôn đặt mức ưu tiên thấp nhất để không làm phiền người chơi).

B. Phân tích các Rule mẫu trong HomePopups

Mỗi popup tại Home là một class riêng triển khai interface IPopupRule:

public interface IPopupRule
{
    // Kiểm tra điều kiện riêng của bạn; nếu hợp lệ, hãy đưa vào queue.
    // Tuyệt đối không được mở trực tiếp popup trong hàm này!
    void TryEnqueue(DialogQueue queue);
}

Trong HomePopups.cs, danh sách Rules chứa 4 rule được định nghĩa sẵn:

  1. WelcomePopup (Priority: Welcome = 600): Kiểm tra key GameKeys.PopupWelcomeShown. Nếu chưa từng hiện, đưa MessageDialog vào queue và lưu cờ khi đóng.
  2. LoginCalendarPopup (Priority: Reward = 700): Kiểm tra GameApp.LiveOps.Login.CanClaimToday. Nếu hôm nay có quà chờ nhận, đưa LoginCalendarDialog vào queue kèm điều kiện canShow kiểm tra lại trước lúc mở.
  3. TimedEventIntroPopup (Priority: Feature = 500): Kiểm tra xem có sự kiện thời hạn nào đang chạy mà người chơi chưa xem phần giới thiệu của đợt này (dựa trên OccurrenceKey) hay không.
  4. RateUsPopup (Priority: RateUs = 100): Kiểm tra người chơi đã vượt qua level 5 (MinLevel = 5) và chưa từng bị hỏi đánh giá (GameKeys.PopupRateAsked). Nếu người chơi bấm "RATE", gọi App.Review.RequestReview().

C. Bài tập thực hành: Thêm Rule NewFeaturePopup

🛠️ BÀI TẬP: Thêm Popup mở khóa tính năng khi đạt Level 10

Yêu cầu: Khi người chơi đạt từ Level 10 trở lên, hiển thị một popup thông báo "NEW MODE! Time attack is now unlocked." một lần duy nhất tại màn hình Home.

Các bước thực hiện:

  1. Mở file Assets/_Project/Game/GameKeys.cs, bổ sung key lưu trữ:
    public const string PopupNewFeatureShown = "game.popup.new_feature_shown";
  2. Trong file Assets/_Project/Game/Popups/HomePopups.cs, viết thêm class rule:
    public sealed class NewFeaturePopup : IPopupRule
    {
        private const int UnlockLevel = 10;
    
        public void TryEnqueue(DialogQueue queue)
        {
            if (LocalStorage.GetBool(GameKeys.PopupNewFeatureShown) || 
                GameApp.Progress.CurrentLevel < UnlockLevel) return;
    
            queue.Enqueue<MessageDialog>(PopupPriority.Feature,
                d => d.Setup("NEW MODE!", "Time attack is now unlocked.", "TRY IT",
                    onResult: _ =>
                    {
                        LocalStorage.SetBool(GameKeys.PopupNewFeatureShown, true);
                        LocalStorage.Save();
                    }),
                id: nameof(NewFeaturePopup));
        }
    }
  3. Thêm instance của rule vào mảng Rules trong HomePopups:
    private static readonly IPopupRule[] Rules =
    {
        new WelcomePopup(),
        new LoginCalendarPopup(),
        new TimedEventIntroPopup(),
        new NewFeaturePopup(), // Thêm vào danh sách!
        new RateUsPopup(),
    };

6. Quy trình tạo Dialog mới trong dự án

A. Công cụ tự động: TapBrain > Create > New Dialog...

Template tích hợp sẵn công cụ sinh mã nguồn và Prefab tự động qua Editor Window DialogScaffold.cs:

  1. Chọn menu TapBrain → Create → New Dialog....
  2. Nhập tên class dạng PascalCase kết thúc bằng đuôi Dialog (ví dụ: ShopDialog).
  3. Nhấn nút Create. Unity sẽ:
    • Tạo file script C# chuẩn tại Assets/_Project/Game/UI/Dialogs/ShopDialog.cs kế thừa DialogBase.
    • Chờ Unity biên dịch xong mã nguồn (bắt qua hook [DidReloadScripts]).
    • Tự động dựng Prefab chuẩn tại Assets/_Project/Resources/Dialogs/ShopDialog.prefab gồm nền tối mờ (alpha 0.6), panel Content (860x900), tiêu đề TextMeshPro, nút đóng.
    • Tự động tìm và gán (wire) các trường [SerializeField] trên component theo tên chuẩn!
📋 Danh sách các trường được tự động nối dây (Auto-wired Fields)

Hàm BuildPrefab() trong DialogScaffold sẽ tự động tìm và kéo thả component vào các biến sau nếu script của bạn khai báo chúng:

  • content (RectTransform của khung panel chính)
  • titleText (TextMeshProUGUI hiển thị tiêu đề)
  • messageText (TextMeshProUGUI hiển thị nội dung)
  • okButton & okLabel (Nút OK và Text của nút)
  • cancelButton & cancelLabel (Nút Cancel và Text của nút)
  • closeButton (Nút X đóng popup ở góc trên bên phải)

Nếu bạn tự tay viết code C# trước, chỉ cần chọn file script đó trong cửa sổ Project rồi chọn menu TapBrain → Create → Dialog Prefab From Selected Script để Unity tự sinh prefab tương ứng.

B. Khi nào nên tái sử dụng MessageDialog?

Đừng tạo mới class và prefab một cách bừa bãi! Hãy tái sử dụng MessageDialog khi popup chỉ cần:

  • Một tiêu đề ngắn (Title) + Đoạn văn bản mô tả (Message).
  • Một nút bấm xác nhận (OK / Got It / Let's Go / Try It) hoặc kèm thêm một nút hủy (Cancel / Later).
  • Không chứa hình ảnh phức tạp, danh sách cuộn (ScrollView) hay logic tính toán đặc thù.

C. Ví dụ hoàn chỉnh: Xây dựng ShopDialog tích hợp IAP

Dưới đây là mã nguồn hoàn chỉnh của một ShopDialog tự viết tay theo chuẩn kiến trúc của template, mua gói Coin bằng IAP và tuân thủ tuyệt đối Contract callback:

// Assets/_Project/Game/UI/Dialogs/ShopDialog.cs
using System;
using TapBrain.Core;
using TapBrain.Core.Services;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

namespace TapBrain.Game
{
    public sealed class ShopDialog : DialogBase
    {
        [SerializeField] private TextMeshProUGUI titleText;
        [SerializeField] private Button buyCoinsButton;
        [SerializeField] private TextMeshProUGUI buyPriceText;
        [SerializeField] private Button closeButton;

        private Action<bool> _onClosed;
        private bool _purchasedAny;

        public ShopDialog Setup(Action<bool> onClosed = null)
        {
            _onClosed = onClosed;
            if (titleText != null) titleText.text = "COIN SHOP";
            if (buyPriceText != null) buyPriceText.text = "$0.99";
            return this;
        }

        protected override void OnOpened()
        {
            if (buyCoinsButton != null) buyCoinsButton.onClick.AddListener(OnBuyCoins);
            if (closeButton != null) closeButton.onClick.AddListener(Close);
        }

        protected override void OnClosed()
        {
            if (buyCoinsButton != null) buyCoinsButton.onClick.RemoveListener(OnBuyCoins);
            if (closeButton != null) closeButton.onClick.RemoveListener(Close);

            // Bắt buộc gọi callback báo kết quả tại OnClosed
            var callback = _onClosed;
            _onClosed = null;
            callback?.Invoke(_purchasedAny);
        }

        private void OnBuyCoins()
        {
            if (IsClosing) return;

            // Bật lớp blocker chống bấm loạn màn hình trong khi chờ Store phản hồi
            App.Overlay.ShowBlocker();

            App.Iap.Purchase(GameKeys.ProductCoinsSmall, result =>
            {
                App.Overlay.HideBlocker();

                // result is a PurchaseResult enum: Success, Cancelled, Failed, Pending, NotReady
                switch (result)
                {
                    case PurchaseResult.Success:
                        _purchasedAny = true; // coins were already granted by GameWallet.GrantProduct
                        App.Overlay.Toast("Purchase successful! Coins added.");
                        Close();
                        break;
                    case PurchaseResult.Cancelled:
                        App.Overlay.Toast("Purchase cancelled.");
                        break;
                    case PurchaseResult.Pending:
                        App.Overlay.Toast("Purchase pending, it will be delivered when confirmed.");
                        break;
                    default:
                        App.Overlay.Toast("Purchase failed, please try again.");
                        break;
                }
            });
        }
    }
}

Phần B: LiveOps (Sự kiện giữ chân người chơi)

7. LiveOps là gì & Tại sao Game Prototype bắt buộc phải có?

LiveOps (Live Operations) là tập hợp các hoạt động vận hành, cập nhật nội dung, tổ chức sự kiện và cung cấp phần thưởng diễn ra liên tục mà không cần phải thay đổi mã nguồn cốt lõi của game. Trong các game mobile chuyên nghiệp, LiveOps chính là xương sống tạo nên chỉ số D1 Retention (giữ chân ngày 1) và D7 Retention (giữ chân ngày 7).

A. Tâm lý học hành vi đằng sau LiveOps

  • Vòng lặp thói quen (Habit Loop): Gợi ý (Trigger) → Hành động (Action) → Phần thưởng biến đổi (Variable Reward) → Đầu tư (Investment). Quà đăng nhập và chuỗi ngày chơi thúc đẩy người chơi mở game ít nhất một lần mỗi ngày vào cùng một khung giờ.
  • Tâm lý sợ mất mát (Loss Aversion): Con người sợ bị tước đoạt thứ mình đang sở hữu hơn là hào hứng nhận thứ mới. Khi một người chơi đã tích lũy được chuỗi chơi 6 ngày liên tiếp (Streak 6), họ sẽ quyết tâm vào chơi ít nhất 1 ván vào ngày thứ 7 để không bị reset chuỗi về số 1.
  • Tâm lý sợ bỏ lỡ (FOMO - Fear Of Missing Out): Các sự kiện có hạn định thời gian (như Star Hunt chạy trong 7 ngày) tạo ra áp lực thời gian tích cực, thôi thúc người chơi hoàn thành các mốc thưởng trước khi đồng hồ đếm ngược về 0.

B. Tại sao ngay cả bản Prototype sơ khai cũng cần LiveOps?

Khi đưa một bản prototype lên các đợt test thị trường (Facebook Ads, TikTok Ads, Google App Campaigns), publisher sẽ đo đạc trực tiếp hai chỉ số: CPI (Giá mỗi lượt cài đặt) và D1/D7 Retention. Một gameplay cực hay nhưng thiếu LiveOps sẽ có chỉ số D1 tụt thảm hại (dưới 25%), khiến dự án bị đánh giá là không có tiềm năng và bị khai tử (kill project) ngay từ giai đoạn trứng nước. Với template TapBrain, toàn bộ hệ thống LiveOps đã được tích hợp sẵn 100%, sẵn sàng cho các bài test số liệu khắt khe nhất.


8. Kiến trúc tổng thể hệ thống LiveOps trong Template

A. Bộ điều phối trung tâm LiveOps, GameClock & LiveOpsConfig

Hệ thống LiveOps được tổ chức chặt chẽ theo mô hình Decoupled Architecture (Kiến trúc tách rời):

  • LiveOpsConfig (ScriptableObject tại Resources/LiveOpsConfig.asset): Lưu trữ toàn bộ chỉ số, mốc thưởng, danh sách nhiệm vụ và sự kiện. Nằm trong bản build.
  • GameClock: Đại diện cho đồng hồ thời gian của game. Toàn bộ tính năng LiveOps đều đọc mốc thời gian qua GameClock.Now và GameClock.Today, cho phép dịch chuyển thời gian để test mà không cần đổi giờ hệ thống của điện thoại.
  • LiveOps (Coordinator tại GameApp.LiveOps): Điều phối 4 service nghiệp vụ chuyên biệt: LoginCalendarService, StreakService, DailyQuestService, và TimedEventService.

B. Cơ chế ghi nhận sự kiện & Ghi dữ liệu nguyên tử (Atomic Save)

Gameplay trong Scene Main chỉ báo cáo dữ kiện thuần túy thông qua các lệnh gọi trực tiếp, không tự ý tính toán quà hay can thiệp vào UI:

// GameplayController.cs chỉ báo cáo sự thật
GameApp.LiveOps.OnLevelCompleted(won); // Báo cáo ván chơi kết thúc
GameApp.LiveOps.OnRewardedAdWatched(); // Báo cáo khi xem xong quảng cáo thưởng
🔒 QUY TẮC LƯU TRỮ NGUYÊN TỬ (ATOMIC SAVE)

Khi trao thưởng, Service của tính năng sẽ cập nhật dữ liệu nội bộ và lưu đè vào PlayerPrefs qua LocalStorage.SetJson() (chưa lưu xuống đĩa). Sau đó LiveOps.Grant() sẽ cộng tiền vào GameWallet, và hàm Wallet.Add() gọi LocalStorage.Save() để đẩy toàn bộ trạng thái tính năng cùng số coin mới xuống đĩa trong cùng một lần flush duy nhất.

Điều này đảm bảo: Nếu người chơi tắt app đột ngột, không bao giờ xảy ra lỗi "nhận được quà nhưng tiến độ tính năng bị rollback" hoặc "tiến độ tăng nhưng coin chưa kịp cộng".

Đặc biệt, LiveOps không bao giờ tự ý mở Dialog! Nó đóng gói thông báo vào cấu trúc LiveOpsNotice(Title, Message, AsPopup). Lớp UI của Scene (cụ thể là MainPopups.EnqueueLiveOpsNotices) sẽ quyết định notice nào biến thành Popup đưa vào DialogQueue và notice nào chỉ cần hiện thông báo nhanh dạng Toast.

C. Sơ đồ luồng EndLevel → LiveOps → Queue

Dưới đây là toàn bộ chuỗi hành động diễn ra từ khi ván chơi kết thúc cho tới khi các popup thưởng lần lượt xuất hiện:

flowchart TD
    LevelEnd["GameplayController.EndLevel(won)"] --> FinishSession["1. LevelSession.Finish(outcome)"]
    FinishSession --> GrantWallet["2. Cộng coin & lưu tiến độ"]
    FinishSession --> ReportLiveOps["3. LiveOps.OnLevelCompleted(won)"]
    ReportLiveOps --> CheckStreak["Cộng dồn Streak (lần 1/ngày)"]
    ReportLiveOps --> ReportQuests["Quests.Report: Play & Win Levels"]
    ReportLiveOps --> CheckEvent["Events.AddTokensForWin (sao mốc)"]
    CheckStreak --> AtomicSave["LocalStorage.Save() đồng bộ"]
    ReportQuests --> AtomicSave
    CheckEvent --> AtomicSave
    AtomicSave --> EnqueueNotices["Tạo danh sách LiveOpsNotice"]
    EnqueueNotices --> EnqueueResult["Queue: Enqueue ResultDialog (900)"]
    EnqueueResult --> EnqueueNoticesQueue["Queue: Enqueue notices (700/Toast)"]
    EnqueueNoticesQueue --> SortFrame["Queue tự sắp xếp ưu tiên"]
    SortFrame --> ShowResultFirst["Hiện ResultDialog trước"]
    ShowResultFirst --> ShowRewardsNext["Hiện tiếp popup quà LiveOps"]
    ShowRewardsNext --> IdleChoice["WhenIdle: Xử lý lựa chọn"]
          

9. Chi tiết 4 tính năng LiveOps trong Template

A. LoginCalendarService: Quà đăng nhập 7 ngày lặp lại

  • Luật chơi: Mỗi ngày địa phương người chơi được nhận 1 ô quà kế tiếp. Nếu bỏ lỡ một ngày không vào game, tiến độ KHÔNG bị reset về ngày 1 (tiến độ vẫn giữ nguyên ở ô đang chờ). Khi nhận hết ngày thứ 7, nếu bật cờ loginCalendarLoops thì lịch sẽ tự động quay vòng lại ngày 1.
  • Cấu hình & Giá trị mặc định (trong LiveOpsConfig.cs):
    • loginCalendarEnabled = true
    • loginCalendarLoops = true
    • loginRewards: Mảng 7 phần tử: 20 Coins, 30 Coins, 40 Coins, 50 Coins, 60 Coins, 80 Coins, 200 Coins.
  • Storage Key: game.liveops.login (Lưu class State gồm lastClaimDay và nextIndex).
  • Hiển thị trên UI: Nút calendarButton trên màn hình Home. Đi kèm chấm đỏ calendarBadge khi CanClaimToday == true. Tự động bung popup qua rule LoginCalendarPopup khi người chơi mở Home.
  • Mã nguồn gọi nhận thưởng:
    // Người chơi nhấn nhận quà trong LoginCalendarDialog
    if (GameApp.LiveOps.ClaimLogin(out var reward))
    {
        App.Overlay.Toast($"Claimed {reward}!");
    }
  • Edge cases cần lưu ý:
    • Khi loginCalendarLoops = false, nhận xong ngày thứ 7 thì IsFinished = true và CanClaimToday = false vĩnh viễn.
    • Biến nextIndex là 0-based index đại diện cho ô sắp nhận (từ 0 tới 6).

B. StreakService: Chuỗi ngày chơi liên tục tăng dần

  • Luật chơi: Người chơi chỉ cần hoàn thành ít nhất 1 level (thắng hoặc thua) trong ngày để duy trì chuỗi. Chuỗi tăng +1 mỗi ngày liên tiếp và tặng thưởng tăng dần theo mảng cấu hình. Nếu bỏ lỡ dù chỉ 1 ngày, chuỗi sẽ bị đứt và reset về 1 ở lần hoàn thành level tiếp theo.
  • Cấu hình & Giá trị mặc định:
    • streakEnabled = true
    • streakRewards: Mảng 5 phần tử: 10 Coins, 15 Coins, 20 Coins, 30 Coins, 50 Coins. Các ngày vượt quá độ dài mảng (từ ngày thứ 5 trở đi) sẽ luôn lấy phần thưởng của ngày cuối cùng (50 Coins).
  • Storage Key: game.liveops.streak (Lưu lastPlayDay, count, best).
  • Hiển thị trên UI: Dòng chữ streakText trên Home:
    • Nếu đang có chuỗi: STREAK: X day(s) (kèm lời nhắc - play today to keep it! nếu hôm nay chưa chơi).
    • Nếu chưa có hoặc vừa bị đứt: STREAK: finish a level to start one.
  • Mã nguồn kiểm tra chuỗi trên UI:
    // Thuộc tính Current tự động kiểm tra xem chuỗi có bị đứt hôm qua không
    int currentStreak = GameApp.LiveOps.Streak.Current; 
    // Trả về 0 nếu ngày chơi gần nhất là trước ngày hôm qua (đã đứt)!
  • Edge cases cần lưu ý:
    • Trong cùng 1 ngày, chỉ có ván chơi đầu tiên kích hoạt tăng chuỗi và nhận thưởng (PlayedToday == true thì các ván sau bỏ qua).
    • Nếu người chơi nghỉ 3 ngày rồi quay lại chơi 1 ván, chuỗi sẽ được tính là 1 (bắt đầu lại), và kỷ lục cao nhất best vẫn được bảo tồn.

C. DailyQuestService: Nhiệm vụ hằng ngày tất định

  • Luật chơi: Vào đúng 00:00 nửa đêm địa phương, hệ thống sẽ bốc ngẫu nhiên questsPerDay nhiệm vụ từ kho questPool. Điểm đặc sắc là hàm bốc được gieo seed ngẫu nhiên theo chỉ số ngày (DayNumber): new Random(today). Vì vậy toàn bộ người chơi hoặc cùng 1 người chơi gỡ app cài lại trong cùng ngày đều nhận đúng danh sách nhiệm vụ y hệt nhau!
  • Enum QuestType:
    • PlayLevels: Hoàn thành ván chơi bất kể thắng hay thua.
    • WinLevels: Phải chiến thắng ván chơi.
    • WatchRewardedAds: Xem xong 100% video quảng cáo trả thưởng.
  • Cấu hình & Giá trị mặc định:
    • dailyQuestsEnabled = true
    • questsPerDay = 3 (khoảng chọn từ 1 đến 3)
    • questPool (Kho 5 nhiệm vụ mẫu):
      1. play_3: "Play 3 levels", Target: 3, Reward: 20 Coins.
      2. play_6: "Play 6 levels", Target: 6, Reward: 40 Coins.
      3. win_2: "Win 2 levels", Target: 2, Reward: 25 Coins.
      4. win_5: "Win 5 levels", Target: 5, Reward: 60 Coins.
      5. ad_1: "Watch 1 reward video", Target: 1, Reward: 30 Coins.
  • Storage Key: game.liveops.quests (Lưu day và danh sách QuestState gồm id, progress, claimed).
  • Hiển thị trên UI: Nút questsButton có huy hiệu số lượng nhiệm vụ đã hoàn thành chờ nhận questsBadgeText, mở dialog DailyQuestsDialog. Khi hoàn thành nhiệm vụ trong trận, xuất hiện thông báo Toast QUEST COMPLETE.
  • Mã nguồn nhận thưởng nhiệm vụ:
    if (GameApp.LiveOps.ClaimQuest("win_2", out var reward))
    {
        App.Overlay.Toast($"Claimed quest: {reward}!");
    }
  • Edge cases cần lưu ý: Tiến độ nhiệm vụ không bao giờ vượt quá target (dùng Math.Min). Nhiệm vụ đã nhận (claimed == true) không thể nhận lần hai.

D. TimedEventService: Sự kiện thời hạn (Weekly & Absolute)

  • Luật chơi: Khi sự kiện đang trong khung thời gian hoạt động, mỗi ván thắng sẽ đem lại một lượng sao/token (tokensPerWin). Khi số token đạt các mốc (Milestones), phần thưởng sẽ được tự động cộng thẳng vào tài khoản ngay lập tức mà không cần nút nhận quà (không sợ người chơi quên bấm nhận).
  • Lịch trình Weekly vs Absolute:
    • Weekly: Chạy lặp lại mỗi tuần bắt đầu từ 00:00 sáng ngày weeklyStartDay trong durationDays ngày. Ví dụ: Sự kiện mẫu "STAR HUNT" có weeklyStartDay = DayOfWeek.Monday và durationDays = 7, đồng nghĩa với việc sự kiện luôn luôn chạy xuyên suốt tuần và tự động làm mới sang tuần mới vào mỗi sáng thứ Hai!
    • Absolute: Chạy một lần duy nhất theo mốc thời gian cố định absoluteStartLocal (định dạng chuẩn: yyyy-MM-dd HH:mm) kéo dài trong durationDays ngày. Rất phù hợp cho các sự kiện mùa vụ như Giáng sinh, Tết, Halloween.
  • Cấu hình & Giá trị mặc định ("STAR HUNT"):
    • id = "star_hunt", title = "STAR HUNT"
    • schedule = EventSchedule.Weekly, weeklyStartDay = Monday, durationDays = 7
    • tokensPerWin = 1
    • milestones: 3 mốc tích lũy:
      • Mốc 1: 3 Stars → Thưởng 50 Coins
      • Mốc 2: 8 Stars → Thưởng 120 Coins
      • Mốc 3: 15 Stars → Thưởng 300 Coins
  • Storage Key & Tiến độ từng đợt (OccurrenceKey):

    Dữ liệu được lưu tại key game.liveops.event.<id> (ví dụ: game.liveops.event.star_hunt). Tiến độ được gắn chặt với khóa phân biệt đợt chạy: OccurrenceKey = $"{def.id}@{GameClock.DayNumber(StartsAt)}". Khi sang tuần mới, OccurrenceKey tự động thay đổi, hệ thống phát hiện khóa mới và tạo một bản ghi tiến độ trắng (bắt đầu lại từ 0 Star) mà không cần phải chạy code xóa dữ liệu cũ!

  • Hiển thị trên UI: Nút eventButton trên Home hiển thị tên sự kiện kèm đồng hồ đếm ngược định dạng thông minh (ví dụ: STAR HUNT\n4d 18h), mở TimedEventDialog.

10. Quản lý thời gian: GameClock, Gian lận & DebugOffset

A. Giờ thiết bị và Mốc nửa đêm địa phương (Local Midnight)

Người chơi mobile có thói quen sinh hoạt gắn liền với múi giờ thực tế của họ. Họ mong muốn quà đăng nhập và nhiệm vụ ngày mới xuất hiện vào đúng 00:00 đêm tại nơi họ sinh sống, chứ không phải theo giờ UTC. Vì vậy, GameClock quy đổi ngày dựa trên số ngày trôi qua kể từ ngày 01/01/1970 theo giờ địa phương:

public static int DayNumber(DateTime local) => 
    (int)(local.Date - new DateTime(1970, 1, 1)).TotalDays;

Khoảng thời gian còn lại cho tới ngày mới được tính đơn giản qua: Now.Date.AddDays(1) - Now.

B. Đánh đổi chống gian lận & Điểm mở rộng Server Time

Bằng việc đọc giờ thiết bị (DateTime.Now), người chơi có thể vào Cài đặt điện thoại và chỉnh đồng hồ tiến lên 1 ngày để nhận quà trước. Đây là sự đánh đổi hoàn toàn có chủ đích và hợp lý cho các bản game Prototype:

  • Game chạy offline 100%, không phụ thuộc vào kết nối máy chủ hay dịch vụ thời gian phức tạp.
  • Chi phí phát triển cực nhanh, không phát sinh lỗi chặn người chơi khi mất mạng đột ngột.
  • Điểm mở rộng duy nhất: Khi game được phát hành chính thức (Production) và yêu cầu chống gian lận, bạn chỉ cần sửa đổi DUY NHẤT một dòng code tại thuộc tính GameClock.Now (lấy thời gian đồng bộ từ Server hoặc Firebase NTP Server). Toàn bộ 4 tính năng LiveOps còn lại không phải sửa một dòng code nào!

C. Thuộc tính DebugOffset

GameClock cung cấp thuộc tính public TimeSpan DebugOffset { get; set; }. Bất kỳ giá trị nào cộng vào DebugOffset sẽ làm GameClock.Now thay đổi tương ứng, giúp developer thử nghiệm các mốc ngày mà không hề ảnh hưởng đến đồng hồ hệ thống của máy tính hay điện thoại.


11. Kiểm thử LiveOps siêu tốc với Debug Panel

Trong môi trường Unity Editor hoặc bản build Development, bạn có thể nhấn phím F1 (hoặc chạm nhanh 5 lần vào góc trên bên trái màn hình) để mở DebugServicePanel. Nhóm nút LiveOps cung cấp mọi công cụ bạn cần:

A. 4 công cụ LiveOps trong Debug Panel

  • Clock +1 day: Cộng thêm 24 giờ vào DebugOffset. Lập tức chuyển trạng thái sang ngày mới.
  • Clock reset: Trả DebugOffset về 0, đưa giờ game về lại giờ hiện tại.
  • Report win: Giả lập chiến thắng một level ngay lập tức. Tự động kiểm tra streak, tăng tiến độ nhiệm vụ và cộng sao sự kiện.
  • Reset LiveOps data: Xóa sạch toàn bộ khóa PlayerPrefs của LiveOps và khởi tạo lại các service về trạng thái ban đầu của người chơi mới.

B. Kịch bản kiểm thử (Test Scripts) từng bước

🧪 TEST SCRIPT 1: Kiểm thử Quà đăng nhập 7 ngày
  1. Bấm F1 → Nhấn Reset LiveOps data → Đóng panel.
  2. Tại Home, thấy nút DAILY GIFT có chấm đỏ → Bấm vào nhận quà Ngày 1 (20 coins).
  3. Sau khi nhận, chấm đỏ biến mất. Bấm lại vào nút không thể nhận thêm.
  4. Bấm F1 → Nhấn Clock +1 day → Đóng panel.
  5. Chấm đỏ xuất hiện trở lại ngay lập tức → Bấm vào nhận tiếp quà Ngày 2 (30 coins).
  6. Lặp lại đến ngày 7, xác nhận chu kỳ sau đó tự động vòng về ngày 1.
🧪 TEST SCRIPT 2: Kiểm thử Chuỗi ngày chơi (Streak)
  1. Bấm F1 → Reset LiveOps data → Dòng chữ Home hiện finish a level to start one.
  2. Bấm F1 → Nhấn Report win → Xuất hiện popup 1-DAY STREAK! → Nhận 10 coins.
  3. Dòng chữ Home đổi thành: STREAK: 1 day(s).
  4. Bấm F1 → Nhấn tiếp Report win lần 2 trong ngày → Không hiện thêm popup streak.
  5. Bấm F1 → Nhấn Clock +1 day → Nhấn Report win → Hiện popup 2-DAY STREAK! → Nhận 15 coins.
  6. Bấm F1 → Nhấn Clock +1 day hai lần liên tiếp (bỏ cách 2 ngày không chơi).
  7. Dòng chữ Home đổi về finish a level to start one (chuỗi đã đứt).
  8. Nhấn Report win → Chuỗi bắt đầu lại từ 1-DAY STREAK!.
🧪 TEST SCRIPT 3: Kiểm thử Sự kiện thời hạn (Star Hunt)
  1. Bấm F1 → Reset LiveOps data.
  2. Tại Home, nút EVENT hiển thị STAR HUNT kèm đếm ngược ngày/giờ.
  3. Mở Main (chơi game) → Thắng 3 level liên tiếp.
  4. Ngay sau khi đóng popup kết quả của trận thứ 3, popup mốc thưởng tự động xuất hiện: Milestone reached: 3 stars! Reward: +50 coins.
  5. Số coin được cộng thẳng vào ví mà không cần tìm nút bấm nhận.

12. Công thức tùy biến thường gặp (Recipes)

A. Thêm sự kiện cuối tuần (Weekend Event: Friday + 3 ngày)

Mở file Assets/_Project/Resources/LiveOpsConfig.asset trong Inspector. Tại mảng Timed Events, bấm dấu + để thêm một phần tử:

  • Id: weekend_rush
  • Title: WEEKEND RUSH
  • Description: Rush through levels during the weekend to earn massive rewards!
  • Enabled: Tích chọn true
  • Schedule: Chọn Weekly
  • Weekly Start Day: Chọn Friday
  • Duration Days: Điền 3 (chạy trọn vẹn thứ Sáu, thứ Bảy và Chủ Nhật)
  • Tokens Per Win: 1
  • Milestones: Thiết lập các mốc sao và coin tương ứng.

B. Thêm sự kiện Giáng sinh (Absolute Event)

Cũng trong Timed Events của LiveOpsConfig.asset, tạo sự kiện chạy một lần duy nhất vào dịp lễ Noel:

  • Id: xmas_2026
  • Title: XMAS WONDERLAND
  • Schedule: Chọn Absolute
  • Absolute Start Local: Điền chính xác định dạng 2026-12-24 00:00
  • Duration Days: Điền 3 (kết thúc vào lúc 00:00 ngày 27/12/2026)

C. Thêm loại nhiệm vụ mới (QuestType)

Giả sử bạn muốn thêm nhiệm vụ "Sử dụng 3 lần trợ giúp (Booster)":

  1. Trong LiveOpsConfig.cs, thêm giá trị vào enum:
    public enum QuestType
    {
        PlayLevels,
        WinLevels,
        WatchRewardedAds,
        UseBoosters, // Thêm mới
    }
  2. Trong LiveOps.cs, thêm hàm nhận báo cáo từ gameplay:
    public void OnBoosterUsed()
    {
        AddQuestNotices(Quests.Report(QuestType.UseBoosters, 1));
        LocalStorage.Save();
    }
  3. Trong gameplay (nơi người chơi bấm nút dùng trợ giúp), gọi lệnh:
    GameApp.LiveOps.OnBoosterUsed();
  4. Mở LiveOpsConfig.asset, thêm một QuestDefinition mới vào Quest Pool với Type = UseBoosters.

D. Thêm loại phần thưởng mới (RewardType)

Mặc định template chỉ có thưởng Coins. Để thêm phần thưởng là lượt chơi (Lives) hoặc Trợ giúp (Booster):

  1. Trong LiveOpsConfig.cs, mở rộng enum:
    public enum RewardType
    {
        Coins,
        BoosterHint, // Thêm mới
    }
  2. Trong LiveOps.cs, mở rộng hàm Grant() để xử lý logic cộng vật phẩm tương ứng:
    private void Grant(Reward reward, string source)
    {
        switch (reward.type)
        {
            case RewardType.Coins:
                _wallet.Add(reward.amount); // Đã kèm lệnh lưu
                break;
            case RewardType.BoosterHint:
                // Giả sử bạn có service quản lý kho đồ Inventory
                // ServiceLocator.Get<InventoryService>().AddHint(reward.amount);
                break;
            default:
                Debug.LogWarning($"[LiveOps] Reward type {reward.type} not handled");
                break;
        }
    
        // Gửi sự kiện phân tích Analytics tự động
        App.Analytics.LogEvent("liveops_reward", new Dictionary<string, object>
        {
            { "source", source }, 
            { "type", reward.type.ToString() }, 
            { "amount", reward.amount }
        });
    }

E. Tắt một tính năng LiveOps khi không cần

Mở LiveOpsConfig.asset, bạn chỉ cần bỏ tích chọn các ô checkbox tương ứng:

  • Bỏ tích loginCalendarEnabled → Nút Daily Gift trên Home tự động ẩn hoàn toàn.
  • Bỏ tích streakEnabled → Dòng chữ Streak trên Home và popup chuỗi sau trận sẽ biến mất.
  • Bỏ tích dailyQuestsEnabled → Nút Quests trên Home tự động ẩn.
  • Bỏ tích enabled trên từng Timed Event → Sự kiện đó ngừng hoạt động ngay lập tức.

13. Sai lầm thường gặp & Cách phòng tránh

❌ SAI LẦM 1: Tự ý cộng thưởng trực tiếp mà không qua LiveOps.Grant()

Hậu quả: Bỏ sót việc ghi log sự kiện phân tích liveops_reward và làm mất tính năng lưu nguyên tử (Atomic Save), dẫn đến nguy cơ lỗi đồng bộ nếu game bị tắt giữa chừng.

Khắc phục: Mọi phần thưởng sinh ra từ LiveOps bắt buộc phải được trả qua hàm Grant() trong LiveOps.cs.

❌ SAI LẦM 2: Mở Dialog trực tiếp từ bên trong các Service LiveOps

Hậu quả: Phá vỡ tính độc lập của kiến trúc (tight coupling UI & Logic), khiến logic LiveOps không thể viết Unit Test tự động, đồng thời làm rối loạn hàng đợi DialogQueue.

Khắc phục: LiveOps chỉ tạo thông điệp LiveOpsNotice. Quyền quyết định hiện popup hay toast hoàn toàn thuộc về controller của Scene.

❌ SAI LẦM 3: Đọc DateTime.Now trực tiếp thay vì GameClock.Now

Hậu quả: Khiến các tính năng LiveOps bị vô hiệu hóa trước các công cụ debug thời gian (không thể test bằng nút Clock +1 day), và làm mất điểm neo duy nhất để nâng cấp lên Server Time sau này.

Khắc phục: Luôn luôn đọc Clock.Now hoặc Clock.Today được truyền vào constructor của Service.

❌ SAI LẦM 4: Báo cáo xem video thưởng khi người chơi bấm Skip hoặc xem dở dang

Hậu quả: Người chơi gian lận hoàn thành nhiệm vụ WatchRewardedAds mà không đem lại doanh thu quảng cáo cho studio.

Khắc phục: Chỉ gọi GameApp.LiveOps.OnRewardedAdWatched() khi và chỉ khi callback của quảng cáo trả về kết quả RewardedResult.Rewarded.

❌ SAI LẦM 5: Quên rằng LiveOpsConfig là ScriptableObject đóng gói trong bản build

Hậu quả: Thay đổi số coin trong LiveOpsConfig.asset nhưng kỳ vọng game đang cài trên điện thoại của tester tự đổi số mà không cài bản build mới.

Khắc phục: Mọi chỉnh sửa trên LiveOpsConfig đều yêu cầu xuất bản build APK/IPA mới. Khi muốn cấu hình từ xa không cần build lại, hãy kết hợp với RemoteConfigService.


14. Tài liệu & Kiến thức nền tảng cần học

Để làm chủ hoàn toàn các kỹ thuật kiến trúc phần mềm nâng cao được áp dụng trong chương này, bạn nên đọc kỹ các tài liệu chính thống được tuyển chọn dưới đây: