Chương 7: Hạ tầng & UI Services

Khám phá bộ công cụ hạ tầng cốt lõi (Core Infrastructure) của MGW SDK: Quản lý vòng đời dịch vụ qua ServiceLocator và App facade, quản lý hộp thoại chuyên nghiệp với DialogBase, DialogService và DialogQueue, chuyển cảnh mượt mà qua SceneLoader, che chắn màn hình với UIOverlay, lưu trữ dữ liệu an toàn bằng LocalStorage, cùng các tiện ích âm thanh, rung, coroutine và log runtime.

1. Mục đích & Mô hình tư duy (Mental Model)

Trong phát triển game Unity, các vấn đề phổ biến nhất thường bắt nguồn từ kiến trúc hạ tầng: việc lạm dụng quá nhiều static Instance singleton rải rác, các MonoBehaviour sống dai dẳng không rõ vòng đời, logic hiển thị popup chồng chéo gây lỗi raycast, hay dữ liệu PlayerPrefs bị ghi đè không kiểm soát.

Tầng TapBrain.Core được thiết kế theo 3 nguyên tắc nền tảng:

  • Plain C# Services làm nòng cốt: Hầu hết dịch vụ hạ tầng (như SceneLoader, DialogService, LocalStorage, AudioService) là các lớp C# thuần túy, không kế thừa MonoBehaviour. Điều này giúp loại bỏ rác component trong Hierarchy, dễ viết unit test và kiểm soát vòng đời tường minh.
  • Một MonoBehaviour dịch vụ duy nhất làm cầu nối (CoreHost): SDK chỉ có một MonoBehaviour dịch vụ là CoreHost, gắn trên GameObject [Core], để chạy coroutine và lắng nghe sự kiện vòng đời ứng dụng (Pause, Quit, Back button). Ngoài ra Core tạo thêm hai Canvas bền vững sinh bằng code là [Dialogs] (sorting order 1000) và [CoreOverlay] (sorting order 30000), không chứa MonoBehaviour logic.
  • Phân định ranh giới đăng ký và truy xuất: Chỉ có Bootstrapper (composition root) và các SDK Module được quyền đăng ký dịch vụ vào ServiceLocator. Mã nguồn game chỉ truy xuất thông qua lối tắt App facade hoặc ServiceLocator.Get<T>().

2. ServiceLocator & App Facade

ServiceLocator: Điểm truy xuất tập trung

Lớp tĩnh TapBrain.Core.ServiceLocator lưu trữ registry của toàn bộ các dịch vụ hệ thống. Khi cấu hình Enter Play Mode tắt tính năng Domain Reload, phương thức nội bộ tự động dọn dẹp static dictionary để chống rò rỉ trạng thái giữa các lần bấm Play.

Phương thức Chữ ký Mô tả & Hành vi Runtime
Register<T> void Register<T>(T service) where T : class Đăng ký instance dịch vụ. Ném ArgumentNullException nếu service là null. Chỉ dành riêng cho Bootstrapper và các module SDK.
Get<T> T Get<T>() where T : class Truy xuất dịch vụ loại T. Nếu chưa đăng ký, ném InvalidOperationException: "Service T is not registered. Was the Bootstrapper created?".
TryGet<T> bool TryGet<T>(out T service) where T : class Truy xuất an toàn không ném exception. Trả về true nếu tìm thấy, ngược lại trả về false và gán service = null.

Lối tắt App Facade cho công việc hằng ngày

Để giảm bớt việc gõ dài dòng ServiceLocator.Get<...>(), SDK cung cấp lớp tĩnh TapBrain.Core.App như một facade nhẹ (thin forwarders). Các thuộc tính này không lưu giữ trạng thái mà chuyển tiếp trực tiếp đến ServiceLocator:

Thuộc tính Kiểu dữ liệu trả về Dịch vụ tương ứng
App.Ads IAdsService Quản lý quảng cáo Banner, Interstitial, Rewarded, AppOpen
App.Iap IIapService Quản lý mua hàng In-App Purchase và gói Remove Ads
App.Analytics IAnalyticsService Gửi sự kiện phân tích gameplay, phễu level và hành vi
App.RemoteConfig IRemoteConfigService Truy xuất cấu hình từ xa và ràng buộc dữ liệu (Bind)
App.Dialogs DialogService Mở, đóng và quản lý các hộp thoại giao diện
App.Scenes SceneLoader Chuyển scene bất đồng bộ (fade out, LoadSceneAsync, fade in) với hiệu ứng mờ đen
App.Overlay UIOverlay Màn che mờ, khóa thao tác (Blocker) và thông báo Toast
App.Audio AudioService Phát âm thanh hiệu ứng (SFX) và nhạc nền (Music)
App.Haptic HapticService Kích hoạt rung phản hồi xúc giác trên thiết bị di động
App.Review IReviewService Gọi hộp thoại đánh giá ứng dụng gốc trên Store
Quy tắc chọn lựa giữa App.X và ServiceLocator

- Sử dụng App.X cho 10 dịch vụ dùng thường nhật được liệt kê ở bảng trên.
- Sử dụng ServiceLocator.Get<T>() hoặc ServiceLocator.TryGet<T>() cho các dịch vụ cấu hình hoặc dịch vụ ít dùng (như CoreConfig, UserIdentity, CoreHost, AttService, AppUpdateService, SearchAdsService, hay các dịch vụ đặc thù MGW như FirebaseService, RealtimeDbService, BIService, NotificationService).

3. Hệ thống Popup: DialogBase & DialogService

Cách tạo một Popup kế thừa DialogBase

Mọi cửa sổ popup trong game đều phải kế thừa từ TapBrain.Core.DialogBase. Lớp này kế thừa MonoBehaviour và bắt buộc có component CanvasGroup để phục vụ hoạt ảnh mờ dần (Fade) và co giãn nhẹ (Scale).

Quy chuẩn bắt buộc: Prefab của popup phải được đặt tại đường dẫn Resources/Dialogs/<Tên_Class>.prefab.

using TapBrain.Core;
using UnityEngine;
using UnityEngine.UI;

namespace MyGame.UI
{
    public sealed class PauseDialog : DialogBase
    {
        // Build index of your own home scene (see File > Build Settings).
        private const int HomeSceneIndex = 1;

        [SerializeField] private Button resumeButton;
        [SerializeField] private Button homeButton;

        protected override void Awake()
        {
            base.Awake(); // mandatory: DialogBase caches its CanvasGroup here
            resumeButton.onClick.AddListener(OnResumeClicked);
            homeButton.onClick.AddListener(OnHomeClicked);
        }

        protected override void OnOpened()
        {
            // Called right after the dialog is activated, before the fade-in animation runs.
        }

        protected override void OnClosed()
        {
            // Called after the fade-out animation ends, right before the GameObject is destroyed.
        }

        private void OnResumeClicked()
        {
            Close();
        }

        private void OnHomeClicked()
        {
            Close();
            App.Scenes.Load(HomeSceneIndex);
        }
    }
}
Bắt buộc gọi base.Awake()

Nếu lớp con override Awake() mà quên base.Awake(), trường _group (CanvasGroup) của DialogBase sẽ là null: Open() và Close() ném NullReferenceException trong coroutine hoạt ảnh. Với Close(), sự kiện Closed và Destroy không bao giờ chạy nên dialog bị kẹt trong ngăn xếp.

Thành viên DialogBase Kiểu / Chữ ký Mô tả hoạt động & Hợp đồng
CloseOnBack bool (get) Mặc định là true (qua SerializedField closeOnBack). Khi bật, nhấn phím Back trên Android hoặc phím Escape sẽ tự động đóng popup trên cùng này.
IsClosing bool (get) Trả về true ngay khi Close() được gọi và popup đang trong quá trình chạy hoạt ảnh đóng (mờ dần và thu nhỏ nhẹ, 0.15s).
Closed event Action<DialogBase> Sự kiện phát ra khi popup đóng hoàn toàn và sắp sửa bị Destroy(gameObject).
Close() void Close() Bắt đầu hoạt ảnh đóng. Khóa interactable = false, chạy coroutine giảm alpha từ 1 về 0 và scale của content từ 1 về 0.9 trong 0.15s (Open() chạy ngược lại: alpha 0 lên 1, scale 0.9 lên 1), gọi OnClosed(), kích hoạt sự kiện Closed rồi hủy GameObject.
OnOpened() virtual void OnOpened() Hook cho lớp con ghi đè để khởi tạo dữ liệu, đăng ký lắng nghe sự kiện khi vừa hiển thị.
OnClosed() virtual void OnClosed() Hook cho lớp con dọn dẹp listener, hủy timer hoặc trả kết quả callback.
Điều kiện dừng của Coroutine hoạt ảnh (Animate) trong DialogBase

Coroutine hoạt ảnh đóng/mở (Animate) chạy trong thời lượng cố định AnimDuration = 0.15f giây dựa trên Time.unscaledDeltaTime (không bị ảnh hưởng bởi Time.timeScale = 0 khi tạm dừng game). Coroutine sẽ dừng hẳn khi đạt mốc thời gian hoặc bị dừng cưỡng bức trước khi một animation mới bắt đầu hay khi đối tượng bị tiêu hủy.

DialogService (App.Dialogs): Quản lý chồng Popup

DialogService tạo một Canvas riêng biệt mang tên [Dialogs] với sortingOrder = 1000, gắn cờ DontDestroyOnLoad, thiết lập tỉ lệ màn hình chuẩn 1080x1920 (match 0.5). Toàn bộ prefab sau khi tải từ Resources/Dialogs/ sẽ được lưu vào bộ nhớ đệm (cache) để tránh gọi lại Resources.Load nhiều lần.

Enum DialogShowMode quy định cách popup mới tương tác với các popup sẵn có:

  • DialogShowMode.Stack (Mặc định): Mở đè lên trên đỉnh. Các dialog nằm dưới vẫn giữ nguyên trạng thái mở (ví dụ: mở hộp thoại xác nhận đè lên trên cửa hàng).
  • DialogShowMode.ReplaceTop: Đóng popup ở đỉnh ngăn xếp hiện tại rồi mới mở popup này lên (dùng cho các bước nối tiếp trong một luồng tương tác).
  • DialogShowMode.ReplaceAll: Đóng toàn bộ tất cả popup đang mở trước khi hiển thị popup mới (dùng cho các trường hợp khẩn cấp như mất kết nối, bắt buộc cập nhật).
Thành viên DialogService Chữ ký Mô tả chi tiết
Show<T> T Show<T>(DialogShowMode mode = DialogShowMode.Stack) where T : DialogBase Mở dialog kiểu T. Nếu kiểu này đang mở, SDK trả về instance cũ (tham số mode bị bỏ qua) và ghi log [Dialog] <T> already open, reusing it (mức Info, chỉ hiện khi bật Verbose). Nếu thiếu prefab, ghi log lỗi [Dialog] Missing prefab Resources/Dialogs/<T> with component <T> (luôn hiện) và trả về null.
IsOpen<T> bool IsOpen<T>() where T : DialogBase Kiểm tra xem dialog loại T có đang hiển thị và chưa trong trạng thái đóng hay không.
CloseAll void CloseAll() Duyệt từ đỉnh xuống đáy và gọi Close() trên tất cả các popup đang mở trong ngăn xếp.
IsAnyOpen bool IsAnyOpen { get; } Trả về true nếu có ít nhất một popup đang mở và chưa kích hoạt cờ IsClosing.
Top DialogBase Top { get; } Trả về đối tượng dialog nằm trên cùng của ngăn xếp, hoặc null nếu ngăn xếp rỗng.
DialogOpened event Action<DialogBase> Sự kiện kích hoạt ngay sau khi một dialog được tạo và gọi Open().
DialogClosed event Action<DialogBase> Sự kiện kích hoạt sau khi một dialog hoàn tất quá trình đóng và bị gỡ khỏi ngăn xếp.
CreateQueue DialogQueue CreateQueue() Khởi tạo một phiên điều phối hàng đợi dialog độc lập cho scene hiện tại.
Hợp đồng đóng Popup khi chuyển Scene

Vì Canvas [Dialogs] có cờ DontDestroyOnLoad, trong mã khởi tạo Bootstrapper đã cài đặt sẵn lắng nghe: sceneLoader.SceneLoading += _ => dialogs.CloseAll();. Nhờ đó, bất cứ khi nào scene mới chuẩn bị được tải, toàn bộ dialog của scene cũ đều bị đóng sạch sẽ, ngăn ngừa triệt để lỗi rò rỉ popup hoặc nút bấm gọi về MonoBehaviour đã bị tiêu hủy.

4. Hàng đợi Dialog ưu tiên: DialogQueue

Trong game casual, thời điểm người chơi hoàn thành màn chơi hoặc trở về sảnh thường có rất nhiều popup cần hiển thị: Quà đăng nhập, Thông báo thăng cấp, Rương thưởng, Lời mời đánh giá... Nếu bạn viết một chuỗi lệnh if ... else if ... phức tạp, code sẽ rất dễ gãy vỡ.

DialogQueue giải quyết bài toán này bằng cách hiển thị lần lượt từng popup một (ONE AT A TIME) theo thứ tự ưu tiên (Priority) giảm dần.

Quy tắc hoạt động cốt lõi của DialogQueue

  • Xếp lịch xả hàng đợi (Scheduled Drain): Khi bạn gọi Enqueue liên tiếp trong cùng một frame, hệ thống gom lại và chờ sang frame kế tiếp mới bắt đầu sắp xếp và hiển thị popup có độ ưu tiên cao nhất.
  • Kiểm tra điều kiện trước khi mở (canShow): Hàm delegate Func<bool> canShow được đánh giá lại ngay trước thời điểm popup chuẩn bị bung ra. Nếu trả về false, yêu cầu đó sẽ bị bỏ qua.
  • Tự động tạm dừng (Pause) thông minh: Hàng đợi sẽ đứng chờ nếu đang có bất kỳ dialog nào mở trên màn hình (kể cả dialog mở bên ngoài queue), khi đang có quảng cáo toàn màn hình (Fullscreen Ad) đang chiếu, hoặc khi hàng đợi đang bị gọi Pause().
  • Vòng đời gắn chặt với Scene: Bạn nên tạo DialogQueue trong Start() và gọi Dispose() trong OnDestroy(). Ngoài ra, hàng đợi tự động hủy (Dispose) ngay khi sự kiện SceneLoading phát ra, ngăn không cho các popup tồn đọng bung ra trong lúc scene đang bị giải phóng.
Phương thức / Thuộc tính Chữ ký Mô tả chi tiết
Enqueue<T> void Enqueue<T>(int priority, Action<T> setup = null, Func<bool> canShow = null, string id = null) where T : DialogBase Thêm yêu cầu mở dialog loại T vào hàng đợi. Số priority càng lớn thì mở càng sớm. Nếu cùng priority thì thực hiện theo thứ tự FIFO. Tham số id giúp chống trùng lặp: nếu một yêu cầu có cùng id đang chờ, yêu cầu sau sẽ bị bỏ qua. Lưu ý: callback setup chạy SAU OnOpened() của dialog, vì vậy OnOpened() không được phụ thuộc vào dữ liệu truyền qua setup. Gọi Enqueue sau khi queue đã bị Dispose sẽ bị bỏ qua âm thầm.
Contains bool Contains(string id) Kiểm tra xem trong danh sách đang chờ có yêu cầu nào trùng mã id hay không.
Remove bool Remove(string id) Hủy bỏ một yêu cầu đang chờ theo mã id. Trả về true nếu đã tìm thấy và xóa thành công.
Pause void Pause() Tạm dừng việc bung các dialog kế tiếp (hỗ trợ đếm tham chiếu, cần số lần gọi Resume() tương ứng).
Resume void Resume() Giảm biến đếm tạm dừng. Khi biến đếm về 0, kích hoạt lượt xả hàng đợi kế tiếp.
WhenIdle void WhenIdle(Action callback) Đăng ký callback chạy đúng một lần khi toàn bộ hàng đợi đã xả hết và không còn dialog nào thuộc hàng đợi này hiển thị trên màn hình.
Clear void Clear() Xóa sạch toàn bộ các yêu cầu dialog đang chờ trong hàng đợi.
Dispose void Dispose() Hủy toàn bộ hàng đợi, dọn dẹp các callback và hủy đăng ký các sự kiện quảng cáo, scene loading.
PendingCount int PendingCount { get; } Số lượng yêu cầu dialog hiện đang nằm chờ trong hàng đợi.
IsPaused bool IsPaused { get; } Cho biết hàng đợi có đang trong trạng thái bị tạm dừng hay không.
IsIdle bool IsIdle { get; } Trả về true khi hàng đợi trống rỗng và không có popup nào thuộc queue này đang hiển thị.
Điều kiện dừng của Coroutine xả hàng đợi (DrainNextFrame)

Phương thức ScheduleDrain() khởi chạy coroutine DrainNextFrame() trên CoreHost. Coroutine này nhường quyền điều khiển đúng 1 frame (yield return null) rồi chạy hàm xử lý Drain() và tự động dừng. Quá trình này được bảo vệ bởi cờ _drainScheduled, đảm bảo không bao giờ bị xếp trùng lịch xả trong cùng một frame.

5. Chuyển cảnh với SceneLoader & Loading Screen

SceneLoader (App.Scenes): Chuyển cảnh có hiệu ứng che mờ

Mọi thao tác chuyển màn hình trong game đều được thực hiện qua App.Scenes (lớp TapBrain.Core.SceneLoader), tuyệt đối không gọi trực tiếp SceneManager.LoadScene. SceneLoader tích hợp sẵn việc làm mờ màn hình (Fade Out), tải bất đồng bộ (Async Load), cập nhật tiến trình và làm sáng trở lại (Fade In).

Thành viên SceneLoader Chữ ký Mô tả hoạt động
LoadingSceneIndex const int LoadingSceneIndex = 0; Hằng số quy định index của scene Loading khởi đầu trong Build Settings (luôn là 0).
CurrentIndex int CurrentIndex { get; } Lấy chỉ số build index của scene hiện đang hoạt động (SceneManager.GetActiveScene().buildIndex).
IsLoading bool IsLoading { get; } Trả về true nếu một tác vụ chuyển cảnh bất đồng bộ đang diễn ra.
SceneLoading event Action<int> SceneLoading; Bắn ra chỉ số build index của scene mục tiêu trước khi hiệu ứng Fade Out bắt đầu.
SceneLoaded event Action<int> SceneLoaded; Bắn ra chỉ số build index của scene mới ngay sau khi tải xong scene và trước khi Fade In kết thúc.
Load void Load(int buildIndex, bool fade = true, Action<float> onProgress = null) Bắt đầu quy trình chuyển sang scene buildIndex. Nếu có bật fade, màn hình sẽ mờ dần sang đen, tải ngầm rồi sáng dần trở lại.
Chặn tải đè & Cảnh báo Console

Nếu bạn gọi Load() trong lúc một tiến trình tải khác đang diễn ra, lệnh mới sẽ bị bỏ qua và Console in cảnh báo:
[Scene] Ignored load <index>: another load is running.
Nếu bạn truyền vào chỉ số scene không có trong Build Settings, Console sẽ in lỗi:
[Scene] Scene index <index> is not in Build Settings.
Lưu ý tác dụng phụ: với index không hợp lệ, sự kiện SceneLoading đã phát trước đó (mọi dialog bị đóng, mọi DialogQueue bị Dispose), màn hình vẫn fade out rồi fade in và game ở lại scene cũ.

Cơ chế hoạt động của LoadingScreen

Trong scene khởi động (Build Index = 0), GameObject giao diện gắn script TapBrain.Core.LoadingScreen. Thành phần này chỉ đóng vai trò View hiển thị tiến trình, hoàn toàn không chứa logic khởi tạo SDK (logic khởi tạo nằm trong InitPipeline của Bootstrapper).

Các trường SerializedField cấu hình trên LoadingScreen:

  • progressFill (Image): Thanh trượt hiển thị phần trăm từ 0 đến 1.
  • progressText (TextMeshProUGUI): Nhãn text hiển thị tiến độ dạng "0%" - "100%".
  • versionText (TextMeshProUGUI): Tự động điền "v" + Application.version khi khởi động.
  • nextSceneBuildIndex (int): Chỉ số build index của scene game đầu tiên cần mở sau khi tải xong (mặc định là 1, thường là sảnh Home).

Trong quá trình chờ, LoadingScreen tham chiếu thông số CoreConfig.minLoadingSeconds (mặc định 1.5 giây). Dù pipeline của SDK hoàn thành nhanh đến đâu, thanh tải vẫn di chuyển mượt mà tối thiểu đủ số giây này để tránh giật hình, sau đó chuyển giao sang SceneLoader.Load(nextSceneBuildIndex).

Điều kiện dừng của Coroutine trong LoadingScreen (WaitAndContinue)

Coroutine WaitAndContinue() kết thúc khi pipeline.IsDone == true VÀ thời gian thực trôi qua kể từ lúc mở scene đạt ít nhất minLoadingSeconds. Trong trường hợp đặc biệt nếu Bootstrapper.Pipeline == null (do lỗi nghiêm trọng lúc boot), coroutine sẽ in lỗi [Boot] Bootstrapper did not create the init pipeline... và dừng ngay lập tức (yield break) thay vì treo vô tận.

6. UIOverlay: Màn che, Blocker & Toast

Lớp TapBrain.Core.UIOverlay (truy xuất qua App.Overlay) quản lý một Canvas đỉnh cao nhất mang tên [CoreOverlay] với sortingOrder = 30000. Toàn bộ canvas này được sinh bằng code C#, hoàn toàn không phụ thuộc vào bất kỳ prefab nào nên luôn tồn tại xuyên suốt mọi scene.

Phương thức Chữ ký Mô tả hoạt động & Hành vi Runtime
FadeOut IEnumerator FadeOut() Làm mờ dần màn hình sang màu đen hoàn toàn trong 0.2 giây (unscaled time) và chặn mọi tương tác chuột/chạm (blocksRaycasts = true). Đây là coroutine: phải chạy bằng yield return App.Overlay.FadeOut() hoặc ServiceLocator.Get<CoreHost>().Run(...); chỉ gọi hàm mà không lặp IEnumerator thì không có gì xảy ra.
FadeIn IEnumerator FadeIn() Làm sáng màn hình từ màu đen về trong suốt trong 0.2 giây. Việc chặn tương tác chỉ được gỡ khi hiệu ứng kết thúc (alpha = 0). Cũng là coroutine, dùng như FadeOut.
ShowBlocker void ShowBlocker() Bật màn chắn đen bán trong suốt (alpha 0.5, chặn raycast) với nhãn tĩnh "..." (không có hiệu ứng xoay) để khóa tương tác người dùng, ví dụ khi game tự xử lý một thao tác chờ. SDK không tự gọi hàm này trong luồng IAP. Có cơ chế đếm tham chiếu (ref-counted): mỗi ShowBlocker() phải có đúng một HideBlocker(), kể cả khi thao tác lỗi (đặt HideBlocker() trong finally hoặc trong mọi nhánh callback), nếu không màn hình bị khóa vĩnh viễn.
HideBlocker void HideBlocker() Giảm biến đếm tham chiếu của màn chắn. Chỉ khi tất cả các luồng yêu cầu đều đã giải phóng (biến đếm về 0), màn chắn mới thực sự tắt.
Toast void Toast(string message, float seconds = 2f) Hiển thị thanh thông báo ngắn màu đen mờ ở cạnh dưới màn hình trong khoảng thời gian seconds rồi tự biến mất.
Điều kiện dừng của các Coroutine trong UIOverlay

- FadeTo(target): Kết thúc sau thời lượng cố định FadeDuration = 0.2f giây dựa trên Time.unscaledDeltaTime.
- ToastRoutine(message, seconds): Chờ đủ số giây thực new WaitForSecondsRealtime(seconds) rồi ẩn alpha về 0. Nếu bạn gọi tiếp một Toast() mới trong khi thông báo cũ đang hiện, thông báo cũ sẽ bị CoreHost.Stop ngay lập tức để ưu tiên thông báo mới.

7. Lưu trữ dữ liệu với LocalStorage

Lớp tĩnh TapBrain.Core.LocalStorage là wrapper mỏng, có kiểu, bọc quanh PlayerPrefs của Unity. Lớp này không tự thêm tiền tố cho khóa: quy ước core./game. do lập trình viên tuân thủ. Các hàm Set* không tự gọi Save(); CoreHost tự gọi LocalStorage.Save() khi ứng dụng Pause và Quit.

Quy ước phân vùng tiền tố khóa (Key Prefixes)

  • Tiền tố core.: Dành riêng cho toàn bộ các thiết lập nội bộ của SDK (ví dụ: core.user_id, core.remove_ads, core.sound_on, core.music_on, core.haptic_on). Phía game tuyệt đối không ghi đè vào các khóa này.
  • Tiền tố riêng của game (ví dụ game.): Toàn bộ dữ liệu của dự án game (như số vàng game.coins, màn chơi hiện tại game.current_level, túi đồ) bắt buộc phải bắt đầu bằng tiền tố riêng của game để tránh mọi nguy cơ xung đột.

Bảng phương thức LocalStorage

Phương thức Chữ ký Chi tiết kỹ thuật
Has bool Has(string key) Kiểm tra xem khóa có tồn tại hay không.
Delete void Delete(string key) Xóa khóa khỏi bộ nhớ lưu trữ.
Save void Save() Ghi cưỡng bức bộ nhớ tạm xuống ổ đĩa (PlayerPrefs.Save()). Chỉ nên gọi tại các checkpoint (tạm dừng game, thoát ứng dụng, sau khi hoàn tất giao dịch mua hàng).
GetInt / SetInt int GetInt(key, fallback = 0) / void SetInt(key, value) Đọc và ghi giá trị số nguyên 32-bit.
GetBool / SetBool bool GetBool(key, fallback = false) / void SetBool(key, value) Đọc và ghi giá trị boolean (được lưu dưới dạng int 0 hoặc 1).
GetFloat / SetFloat float GetFloat(key, fallback = 0f) / void SetFloat(key, value) Đọc và ghi giá trị số thực float.
GetString / SetString string GetString(key, fallback = "") / void SetString(key, value) Đọc và ghi chuỗi string (tự động đổi null thành chuỗi rỗng).
GetLong / SetLong long GetLong(key, fallback = 0) / void SetLong(key, value) PlayerPrefs không hỗ trợ số 64-bit: SDK lưu trữ dưới dạng chuỗi chuẩn hóa CultureInfo.InvariantCulture để chống lỗi dấu phân cách thập phân.
GetDouble / SetDouble double GetDouble(key, fallback = 0) / void SetDouble(key, value) Lưu trữ chuỗi chuẩn định dạng "R" (round-trip) với InvariantCulture để đảm bảo độ chính xác tuyệt đối của số thực 64-bit.
GetJson<T> / SetJson<T> T GetJson<T>(key) where T : class, new() / void SetJson<T>(key, value) Tuần tự hóa đối tượng thông qua JsonUtility. Nếu file json bị lỗi định dạng, SDK bắt ngoại lệ, ghi log [Storage] Corrupted json at '<key>': ... và an toàn trả về new T().

8. Âm thanh (AudioService) & Rung phản hồi (HapticService)

AudioService (App.Audio)

Hệ thống âm thanh được quản lý bởi TapBrain.Core.AudioService gắn trên GameObject gốc [Core] với 2 AudioSource riêng biệt: một kênh lặp vô tận dành cho nhạc nền (Music) và một kênh phát một lần cho hiệu ứng âm thanh (SFX).

  • SoundOn (bool, mặc định true): Bật/tắt SFX; SoundOn = false làm PlaySfx không phát gì. Tự động lưu giá trị vào khóa core.sound_on.
  • MusicOn (bool, mặc định true): Bật/tắt nhạc nền. Chỉ mute kênh nhạc (clip vẫn được đặt và Play()), bật lại sẽ nghe tiếp ngay. Tự động lưu giá trị vào khóa core.music_on.
  • PlaySfx(AudioClip clip, float volume = 1f): Phát hiệu ứng âm thanh một lần nếu SoundOn == true.
  • PlayMusic(AudioClip clip, float volume = 1f): Đặt clip và phát nhạc nền lặp vô tận. Nếu clip đó đang phát thì bỏ qua không phát lại từ đầu.
  • StopMusic(): Dừng phát nhạc nền ngay lập tức.
  • SetPausedByAd(bool paused): Gán AudioListener.pause = paused. Hàm này do MaxAdsService (Provider MGW) gọi quanh quảng cáo toàn màn hình; game không nên gọi trực tiếp. Provider SayKit không gọi hàm này.

HapticService (App.Haptic)

Dịch vụ rung TapBrain.Core.Services.HapticService mang lại trải nghiệm phản hồi xúc giác tinh tế mà không cần cài đặt thêm plugin bên thứ ba:

  • Trên iOS: Tích hợp trực tiếp với bộ phát xúc giác UIFeedbackGenerator của hệ điều hành thông qua plugin HapticFeedback.m.
  • Trên Android: Gọi vibrate(ms) của dịch vụ Vibrator với độ dài tính bằng mili-giây theo từng HapticType (bảng dưới).
  • Trong Editor và nền tảng khác: Play() không làm gì.

Enum TapBrain.Core.Services.HapticType cung cấp 7 mức độ rung:

HapticType Giá trị Thời lượng trên Android Trường hợp sử dụng phù hợp
Selection010 msNhấp chọn nút bấm UI, chuyển tab, lật trang
Light115 msChạm nhẹ trong gameplay, ghép đồ vật nhỏ
Medium225 msĂn combo, va chạm trung bình
Heavy340 msNổ bom, va đập mạnh, mất mạng
Success430 msHoàn thành màn chơi, nhận thưởng
Warning530 msSắp hết thời gian, cảnh báo nguy hiểm
Failure630 msThua cuộc, hết lượt di chuyển

Thuộc tính App.Haptic.Enabled (lưu tại core.haptic_on) cho phép người chơi bật/tắt toàn bộ tính năng rung trong màn hình cài đặt.

9. CoreHost, UserIdentity & CoreLog

CoreHost: Chạy Coroutine từ lớp Plain C#

Khi thiết kế kiến trúc game theo hướng Plain C#, các service không phải MonoBehaviour sẽ không thể gọi StartCoroutine. CoreHost đóng vai trò là "vật chủ" đại diện duy nhất của SDK:

  • Run(IEnumerator routine): Chạy coroutine và trả về đối tượng Coroutine.
  • Stop(Coroutine routine): Dừng một coroutine đang chạy nếu đối tượng khác null.
  • Delay(float seconds, Action action): Hẹn giờ thực hiện một hành động sau seconds giây thực (sử dụng WaitForSecondsRealtime).
  • ApplicationPaused (event Action<bool>): Phát ra khi game bị Pause/Resume. Khi paused = true, SDK tự động gọi LocalStorage.Save() trước khi phát sự kiện ra ngoài.
  • ApplicationQuitting (event Action): Phát ra khi game sắp thoát. Tự động gọi LocalStorage.Save().
  • BackPressed (event Action): Phát ra khi phát hiện phím KeyCode.Escape (nút Back trên Android). Dùng Input.GetKeyDown của Input Manager cũ, nên Active Input Handling phải là Input Manager (Old) hoặc Both.

UserIdentity: Định danh người dùng đồng bộ

Dịch vụ TapBrain.Core.UserIdentity (truy xuất qua ServiceLocator.Get<UserIdentity>()) sinh ra một mã định danh duy nhất cho mỗi lượt cài đặt game, được dùng chung cho tất cả các mạng phân tích và quảng cáo (Firebase, AppLovin MAX, GameAnalytics, AppMetrica, BI).

  • UserId: Chuỗi định danh gồm Unix timestamp (giây) kèm 4 chữ số ngẫu nhiên (ví dụ 17300012349876).
  • IsNewUser: Trả về true nếu đây là phiên chạy đầu tiên sau khi cài mới game.
  • InstallDateUtc: Thời điểm cài đặt tính theo chuẩn UTC.
  • FirstVersion: Phiên bản game lúc người chơi cài đặt lần đầu (Application.version).
  • SessionCount: Số lượt khởi chạy ứng dụng (tự động tăng thêm 1 sau mỗi lần mở game).
  • DaysSinceInstall: Số ngày đã trôi qua kể từ ngày cài đặt.

CoreLog: Chuẩn hóa xuất bản Log

Lớp tĩnh TapBrain.Core.CoreLog chuẩn hóa định dạng log với tiền tố thẻ [Tag]:

  • CoreLog.Verbose: Cờ kiểm soát. Tự động bật nếu CoreConfig.verboseLog == true hoặc đang ở chế độ Debug Build (Debug.isDebugBuild).
  • CoreLog.Info(tag, message): Chỉ in ra Console khi cờ Verbose == true (tức CoreConfig.verboseLog bật hoặc là development build). Định dạng: [tag] message. Mọi dòng log mức Info được nhắc trong chương này (ví dụ [Dialog] ... already open, reusing it) đều tuân theo quy tắc này.
  • CoreLog.Warn(tag, message): Luôn in cảnh báo ra Console, kể cả bản release.
  • CoreLog.Error(tag, message): Luôn in lỗi đỏ ra Console, kể cả bản release.

10. Ví dụ C# Thực tế Hoàn chỉnh

Ví dụ 1: Xây dựng SettingsDialog hoàn chỉnh

Dưới đây là một hộp thoại Cài đặt hoàn chỉnh kế thừa DialogBase, điều khiển đầy đủ âm thanh, nhạc nền, rung phản hồi và mở đánh giá game:

using TapBrain.Core;
using TapBrain.Core.Services;
using UnityEngine;
using UnityEngine.UI;

namespace MyGame.UI
{
    public sealed class SettingsDialog : DialogBase
    {
        [Header("Toggles")]
        [SerializeField] private Toggle soundToggle;
        [SerializeField] private Toggle musicToggle;
        [SerializeField] private Toggle hapticToggle;

        [Header("Buttons")]
        [SerializeField] private Button reviewButton;
        [SerializeField] private Button closeButton;

        protected override void Awake()
        {
            base.Awake();

            // Initial state comes from the SDK; set it before adding listeners so no handler fires.
            soundToggle.isOn = App.Audio.SoundOn;
            musicToggle.isOn = App.Audio.MusicOn;
            hapticToggle.isOn = App.Haptic.Enabled;

            soundToggle.onValueChanged.AddListener(OnSoundChanged);
            musicToggle.onValueChanged.AddListener(OnMusicChanged);
            hapticToggle.onValueChanged.AddListener(OnHapticChanged);

            reviewButton.onClick.AddListener(OnReviewClicked);
            closeButton.onClick.AddListener(Close);
        }

        protected override void OnClosed()
        {
            // Optional here: these listeners live on this dialog's own children and die with it.
            // Unsubscribing is mandatory only for events of longer-lived objects (services, static events).
            soundToggle.onValueChanged.RemoveListener(OnSoundChanged);
            musicToggle.onValueChanged.RemoveListener(OnMusicChanged);
            hapticToggle.onValueChanged.RemoveListener(OnHapticChanged);
            reviewButton.onClick.RemoveListener(OnReviewClicked);
            closeButton.onClick.RemoveListener(Close);
        }

        private void OnSoundChanged(bool on)
        {
            App.Audio.SoundOn = on;
            App.Haptic.Play(HapticType.Selection);
        }

        private void OnMusicChanged(bool on)
        {
            App.Audio.MusicOn = on;
            App.Haptic.Play(HapticType.Selection);
        }

        private void OnHapticChanged(bool on)
        {
            App.Haptic.Enabled = on;
            if (on) App.Haptic.Play(HapticType.Light);
        }

        private void OnReviewClicked()
        {
            App.Haptic.Play(HapticType.Selection);
            // The OS rate-limits this prompt and never reports the result: do not tie rewards to it.
            App.Review.RequestReview();
        }
    }
}

Ví dụ 2: Điều khiển luồng kết thúc màn chơi với DialogQueue

Kịch bản điều phối kết thúc màn chơi bằng lớp Plain C#: Đẩy các popup thưởng và quà tặng vào DialogQueue theo thứ tự ưu tiên, đăng ký WhenIdle để chuyển scene khi toàn bộ popup đã đóng. Queue thuộc về scene: controller của scene tạo trong Start() và Dispose() trong OnDestroy():

using System;
using TapBrain.Core;
using TapBrain.Core.Services;
using UnityEngine;

namespace MyGame.Gameplay
{
    // Plain C# coordinator. LevelSummaryDialog and LuckyBonusDialog are your own DialogBase
    // subclasses with prefabs under Resources/Dialogs/.
    public sealed class LevelEndCoordinator : IDisposable
    {
        // Build index of your own next-level scene (see File > Build Settings).
        private const int NextLevelSceneIndex = 2;

        private readonly DialogQueue _queue;
        private readonly int _completedLevel;
        private readonly bool _hasBonusReward;

        public LevelEndCoordinator(int completedLevel, bool hasBonusReward)
        {
            _completedLevel = completedLevel;
            _hasBonusReward = hasBonusReward;

            // One queue per scene: the owner creates this object in Start and disposes it in OnDestroy.
            _queue = App.Dialogs.CreateQueue();
        }

        public void Execute()
        {
            App.Haptic.Play(HapticType.Success);

            // 1. Main reward popup (highest priority). 'setup' runs AFTER the dialog's OnOpened().
            _queue.Enqueue<LevelSummaryDialog>(
                priority: 100,
                setup: dialog => dialog.SetLevelScore(_completedLevel, 3),
                id: "level_summary"
            );

            // 2. Lucky bonus popup: canShow is re-checked right before it would open.
            _queue.Enqueue<LuckyBonusDialog>(
                priority: 50,
                canShow: () => _hasBonusReward,
                id: "lucky_bonus"
            );

            // 3. Runs once, after every queued popup has been shown and closed.
            _queue.WhenIdle(() =>
            {
                // onClosed fires exactly once, also when the interstitial is skipped (then synchronously).
                App.Ads.TryShowInterstitial("level_end", onClosed: () =>
                {
                    App.Scenes.Load(NextLevelSceneIndex, fade: true);
                });
            });
        }

        public void Dispose()
        {
            _queue.Dispose(); // the queue also disposes itself when SceneLoading fires
        }
    }

    // The scene owns the coordinator: create in Start, Dispose in OnDestroy.
    public sealed class GameplaySceneController : MonoBehaviour
    {
        private LevelEndCoordinator _levelEnd;

        private void Start()
        {
            _levelEnd = new LevelEndCoordinator(completedLevel: 5, hasBonusReward: true);
        }

        // Call this from your own win-detection code (UnityEvent, button, another controller).
        public void OnLevelWon()
        {
            _levelEnd.Execute();
        }

        private void OnDestroy()
        {
            _levelEnd?.Dispose();
        }
    }
}

11. Các sai lầm thường gặp (So sánh Sai vs Đúng)

Cách làm sai (Dễ lỗi)
// 1. Calling SceneManager directly: no fade, no SceneLoading event
SceneManager.LoadScene(1);

// 2. Looking up and toggling a dialog by hand: bypasses the dialog stack
var dialog = FindObjectOfType<SettingsDialog>();
dialog.gameObject.SetActive(true);

// 3. Raw PlayerPrefs key without a prefix, saved on every write
PlayerPrefs.SetInt("coins", 500);
PlayerPrefs.Save();

// 4. Two popups opened at once, no queue: they stack on top of each other
App.Dialogs.Show<LevelSummaryDialog>();
App.Dialogs.Show<LuckyBonusDialog>();
Cách làm đúng chuẩn SDK
// 1. App.Scenes: fade, raycast blocking, SceneLoading/SceneLoaded events
//    (HomeSceneIndex = the build index of your own scene)
App.Scenes.Load(HomeSceneIndex, fade: true);

// 2. Open through App.Dialogs (managed stack, one instance per type)
App.Dialogs.Show<SettingsDialog>();

// 3. LocalStorage with your own "game." prefix
LocalStorage.SetInt("game.coins", 500);
// Call LocalStorage.Save() only at checkpoints (CoreHost also saves on Pause/Quit)

// 4. DialogQueue: one popup at a time, by priority
//    Create it in Start(), call _queue.Dispose() in OnDestroy()
_queue = App.Dialogs.CreateQueue();
_queue.Enqueue<LevelSummaryDialog>(priority: 100);
_queue.Enqueue<LuckyBonusDialog>(priority: 50);

12. Checklist kiểm tra nhanh

Mọi popup kế thừa DialogBase đều có file prefab tại Assets/.../Resources/Dialogs/<Tên_Class>.prefab và chứa component CanvasGroup.
Gỡ các đăng ký lên đối tượng sống lâu hơn dialog (service, event tĩnh, Closed của dialog khác) trong OnClosed(). Listener trên UI con của chính dialog không cần gỡ vì bị hủy cùng GameObject.
Luôn gọi queue.Dispose() trong OnDestroy() của controller điều phối giao diện.
Sử dụng tiền tố game. cho toàn bộ khóa của game và không gọi LocalStorage.Save() liên tục sau mỗi lần cập nhật số lẻ.
Đảm bảo chỉ số scene hợp lệ trong Build Settings và không gọi Load() đè khi App.Scenes.IsLoading == true.