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àoServiceLocator. Mã nguồn game chỉ truy xuất thông qua lối tắtAppfacade hoặcServiceLocator.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 |
- 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);
}
}
}
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. |
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. |
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
Enqueueliê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 delegateFunc<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
DialogQueuetrongStart()và gọiDispose()trongOnDestroy(). Ngoài ra, hàng đợi tự động hủy (Dispose) ngay khi sự kiệnSceneLoadingphá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ị. |
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. |
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.versionkhi 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).
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. |
- 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ànggame.coins, màn chơi hiện tạigame.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 địnhtrue): Bật/tắt SFX;SoundOn = falselàmPlaySfxkhông phát gì. Tự động lưu giá trị vào khóacore.sound_on.MusicOn(bool, mặc địnhtrue): 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óacore.music_on.PlaySfx(AudioClip clip, float volume = 1f): Phát hiệu ứng âm thanh một lần nếuSoundOn == 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ánAudioListener.pause = paused. Hàm này doMaxAdsService(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
UIFeedbackGeneratorcủa hệ điều hành thông qua pluginHapticFeedback.m. - Trên Android: Gọi
vibrate(ms)của dịch vụVibratorvới độ dài tính bằng mili-giây theo từngHapticType(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 |
|---|---|---|---|
Selection | 0 | 10 ms | Nhấp chọn nút bấm UI, chuyển tab, lật trang |
Light | 1 | 15 ms | Chạm nhẹ trong gameplay, ghép đồ vật nhỏ |
Medium | 2 | 25 ms | Ăn combo, va chạm trung bình |
Heavy | 3 | 40 ms | Nổ bom, va đập mạnh, mất mạng |
Success | 4 | 30 ms | Hoàn thành màn chơi, nhận thưởng |
Warning | 5 | 30 ms | Sắp hết thời gian, cảnh báo nguy hiểm |
Failure | 6 | 30 ms | Thua 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ượngCoroutine.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 sausecondsgiây thực (sử dụngWaitForSecondsRealtime).ApplicationPaused(eventAction<bool>): Phát ra khi game bị Pause/Resume. Khi paused = true, SDK tự động gọiLocalStorage.Save()trước khi phát sự kiện ra ngoài.ApplicationQuitting(eventAction): Phát ra khi game sắp thoát. Tự động gọiLocalStorage.Save().BackPressed(eventAction): Phát ra khi phát hiện phímKeyCode.Escape(nút Back trên Android). DùngInput.GetKeyDowncủ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ềtruenế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ếuCoreConfig.verboseLog == truehoặc đang ở chế độ Debug Build (Debug.isDebugBuild).CoreLog.Info(tag, message): Chỉ in ra Console khi cờVerbose == true(tứcCoreConfig.verboseLogbậ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)
// 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>();
// 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
DialogBase đều có file prefab tại Assets/.../Resources/Dialogs/<Tên_Class>.prefab và chứa component CanvasGroup.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.queue.Dispose() trong OnDestroy() của controller điều phối giao diện.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ẻ.Load() đè khi App.Scenes.IsLoading == true.