TapBrain Template

Chương 8: Tutorial Thực hành - 7 Bài tập Nâng cao Năng lực Dự án

🎯 Cẩm nang thực hành thực chiến

Chương này gồm đúng 7 bài tập lập trình thực hành giúp lập trình viên (đặc biệt là các bạn có khoảng 6 tháng kinh nghiệm Unity) làm quen với luồng kiến trúc chuẩn mực của TapBrain Template. Mỗi bài tập đều có đầy đủ: Mục tiêu, Các file cần can thiệp, Mã nguồn hoàn chỉnh 100%, Cách kiểm chứng trực tiếp trong Editor, và Bài tập mở rộng để rèn luyện tư duy độc lập.

⚠️ Quy ước vàng trước khi gõ code

Toàn bộ code logic gameplay và meta service của bạn phải nằm dưới thư mục Assets/_Project/Game/. Không chỉnh sửa Core, không thêm asmdef vào thư mục Game/, không dùng mẫu Singleton cổ điển (static Instance), và luôn hủy đăng ký sự kiện (event unsubscription) trong OnDisable hoặc OnDestroy để tránh rò rỉ bộ nhớ.

Bài 1: Thay thế nút WIN/LOSE bằng cơ chế gameplay thực tế (Tap Targets)

📌 Mục tiêu bài tập

Loại bỏ hai nút bấm giả lập WIN/LOSE tạm thời trên giao diện HUD màn Main, thay thế bằng một mini-mechanic hoàn chỉnh: Chạm vào N mục tiêu (targets) trước khi đồng hồ đếm ngược hết giờ.

  • Người chơi chạm hết N mục tiêu khi thời gian vẫn còn → Thắng, gọi GameplayController.EndLevel(true).
  • Hết giờ đếm ngược mà chưa chạm hết mục tiêu → Thua, gọi GameplayController.EndLevel(false).
  • Tuân thủ triệt để nguyên tắc Tách biệt View và Logic:
    • View (MonoBehaviour): Chỉ nhận diện click, render giao diện mục tiêu, bắn C# event ra ngoài.
    • Logic (Plain C# Class): Nắm giữ quy tắc game, tính toán số mục tiêu còn lại, đếm ngược thời gian, kiểm tra thắng/thua độc lập không phụ thuộc Unity UI.
  • Không dùng LINQ, không cấp phát bộ nhớ (zero GC allocations) trong vòng lặp Update().
  • Hủy đăng ký toàn bộ sự kiện sạch sẽ khi đối tượng bị hủy (OnDestroy/OnDisable).

1.1. Sơ đồ kiến trúc View & Logic

Mô hình tách biệt giúp logic màn chơi có thể viết Unit Test tự động trên C# thuần mà không cần khởi động engine đồ họa Unity:

flowchart LR
    Player["Người chơi tap mục tiêu"] --> View["TapTargetsView (MonoBehaviour)"]
    View -->|"Bắn event TargetClicked"| Ctrl["GameplayController"]
    Ctrl -->|"Cập nhật dữ liệu"| Logic["TapTargetsRules (Plain C#)"]
    Logic -->|"Kiểm tra điều kiện"| State{"Thắng / Thua?"}
    State -->|"Còn thời gian & Hết target"| Win["GameplayController.EndLevel(true)"]
    State -->|"Hết thời gian & Còn target"| Lose["GameplayController.EndLevel(false)"]
    Win --> Result["App.Dialogs.Show<ResultDialog>()"]
    Lose --> Result
          

1.2. Các file can thiệp

  • Assets/_Project/Game/Gameplay/TapTargetsRules.cs (Tạo mới - Plain C# class quản lý quy tắc)
  • Assets/_Project/Game/Gameplay/TapTargetsView.cs (Tạo mới - MonoBehaviour View hiển thị mục tiêu & thời gian)
  • Assets/_Project/Game/UI/MainScreen.cs (Chỉnh sửa - xóa các nút WIN/LOSE tạm)
  • Assets/_Project/Game/Gameplay/GameplayController.cs (Chỉnh sửa - gắn cơ chế mới, kết nối View và Logic)
  • Assets/_Project/Scenes/Main.unity (Xóa UI GameObject WIN/LOSE cũ trên scene)

1.3. Mã nguồn hoàn chỉnh

Bước 1: Tạo class logic thuần C# TapTargetsRules.cs

Lớp này không kế thừa MonoBehaviour, không sử dụng API đồ họa hay UI nào của Unity, hoàn toàn độc lập:

// File: Assets/_Project/Game/Gameplay/TapTargetsRules.cs
namespace TapBrain.Game
{
    // Pure C# class holding round rules. No MonoBehaviour, zero allocations.
    // Can be easily unit-tested without running any Unity scene.
    public sealed class TapTargetsRules
    {
        public int TotalTargets { get; }
        public int RemainingTargets { get; private set; }
        public float TimeRemaining { get; private set; }
        public bool IsFinished { get; private set; }

        public TapTargetsRules(int targetCount, float timeLimitSeconds)
        {
            TotalTargets = targetCount;
            RemainingTargets = targetCount;
            TimeRemaining = timeLimitSeconds;
            IsFinished = false;
        }

        // Returns true if hit was valid and decremented the counter
        public bool RegisterTargetHit()
        {
            if (IsFinished || RemainingTargets <= 0) return false;

            RemainingTargets--;
            return true;
        }

        // Ticked from controller every frame; no heap allocations
        public void UpdateTimer(float deltaTime)
        {
            if (IsFinished) return;

            TimeRemaining -= deltaTime;
            if (TimeRemaining < 0f) TimeRemaining = 0f;
        }

        public bool IsWon => !IsFinished && RemainingTargets <= 0 && TimeRemaining > 0f;
        public bool IsLost => !IsFinished && TimeRemaining <= 0f && RemainingTargets > 0;

        public void MarkFinished()
        {
            IsFinished = true;
        }

        public void AddTime(float extraSeconds)
        {
            if (IsFinished || extraSeconds <= 0f) return;
            TimeRemaining += extraSeconds;
        }
    }
}

Bước 2: Tạo component hiển thị TapTargetsView.cs

Lớp này chịu trách nhiệm hiển thị số target còn lại và thanh đếm thời gian. Lưu ý kỹ thuật tránh cấp phát chuỗi (string allocation) trong Update: chỉ cập nhật text khi số giây nguyên thực sự thay đổi!

// File: Assets/_Project/Game/Gameplay/TapTargetsView.cs
using System;
using TMPro;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.UI;

namespace TapBrain.Game
{
    // View component in Main scene. Displays timer and target buttons.
    // Only raises events; does not decide game win/lose outcomes.
    public sealed class TapTargetsView : MonoBehaviour
    {
        [SerializeField] private TextMeshProUGUI timerText;
        [SerializeField] private TextMeshProUGUI remainingText;
        [SerializeField] private Button[] targetButtons;

        // Fired with the index of the tapped target (index into targetButtons)
        public event Action<int> TargetClicked;

        private UnityAction[] _handlers;
        private int _lastDisplayedSecond = -1;
        private int _lastDisplayedRemaining = -1;

        private void Awake()
        {
            // One closure per button, allocated once: each remembers its own index.
            _handlers = new UnityAction[targetButtons.Length];
            for (int i = 0; i < targetButtons.Length; i++)
            {
                int index = i; // copy: a closure over the loop variable would capture the final value
                _handlers[i] = () => TargetClicked?.Invoke(index);
            }
        }

        private void OnEnable()
        {
            for (int i = 0; i < targetButtons.Length; i++)
            {
                if (targetButtons[i] != null) targetButtons[i].onClick.AddListener(_handlers[i]);
            }
        }

        private void OnDisable()
        {
            for (int i = 0; i < targetButtons.Length; i++)
            {
                if (targetButtons[i] != null) targetButtons[i].onClick.RemoveListener(_handlers[i]);
            }
        }

        public void ResetView(int remainingTargets, float timeRemaining)
        {
            for (int i = 0; i < targetButtons.Length; i++)
            {
                if (targetButtons[i] == null) continue;
                targetButtons[i].gameObject.SetActive(true);
                targetButtons[i].interactable = true;
            }

            _lastDisplayedSecond = -1;
            _lastDisplayedRemaining = -1;
            UpdateDisplay(remainingTargets, timeRemaining);
        }

        // Called every frame: only rebuilds a string when the shown number changes,
        // so a normal frame allocates nothing.
        public void UpdateDisplay(int remainingTargets, float timeRemaining)
        {
            int currentSecond = Mathf.CeilToInt(timeRemaining);
            if (currentSecond != _lastDisplayedSecond)
            {
                _lastDisplayedSecond = currentSecond;
                if (timerText != null) timerText.text = $"TIME: {currentSecond}s";
            }

            if (remainingTargets != _lastDisplayedRemaining)
            {
                _lastDisplayedRemaining = remainingTargets;
                if (remainingText != null) remainingText.text = $"REMAINING: {remainingTargets}";
            }
        }

        public void HideTarget(int targetIndex)
        {
            if (targetIndex < 0 || targetIndex >= targetButtons.Length) return;
            if (targetButtons[targetIndex] != null) targetButtons[targetIndex].gameObject.SetActive(false);
        }

        public void SetInteractable(bool interactable)
        {
            for (int i = 0; i < targetButtons.Length; i++)
            {
                if (targetButtons[i] != null) targetButtons[i].interactable = interactable;
            }
        }
    }
}

Bước 3: Dọn dẹp code nút test cũ trong MainScreen.cs

Gỡ bỏ các trường liên quan đến winButton và loseButton. File MainScreen.cs sau khi dọn sạch sẽ trở nên ngắn gọn:

// File: Assets/_Project/Game/UI/MainScreen.cs
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;

namespace TapBrain.Game
{
    // Main scene HUD (view only): shows level/coins and forwards back clicks.
    public sealed class MainScreen : MonoBehaviour
    {
        [SerializeField] private TextMeshProUGUI levelText;
        [SerializeField] private TextMeshProUGUI coinsText;
        [SerializeField] private Button backButton;

        public event Action BackClicked;

        private void OnEnable()
        {
            if (backButton != null) backButton.onClick.AddListener(OnBack);
        }

        private void OnDisable()
        {
            if (backButton != null) backButton.onClick.RemoveListener(OnBack);
        }

        public void SetLevel(int level)
        {
            if (levelText != null) levelText.text = $"LEVEL {level}";
        }

        public void SetCoins(int coins)
        {
            if (coinsText != null) coinsText.text = coins.ToString();
        }

        private void OnBack() => BackClicked?.Invoke();
    }
}

Bước 4: Cập nhật GameplayController.cs để điều khiển luồng chơi

Xóa bỏ các hàm OnSimulatedWin, OnSimulatedLose; khởi tạo TapTargetsRules trong BeginLevel(); lắng nghe sự kiện click và kiểm tra thắng/thua trong Update():

// File: Assets/_Project/Game/Gameplay/GameplayController.cs
using TapBrain.Core;
using TapBrain.Core.Services;
using UnityEngine;

namespace TapBrain.Game
{
    public sealed class GameplayController : MonoBehaviour
    {
        [SerializeField] private MainScreen screen;
        [SerializeField] private TapTargetsView targetsView;

        private LevelSession _session;
        private GameWallet _wallet;
        private TapTargetsRules _targetRules;
        private bool _paused; // set by the pause dialog (Bài 2); Update() stops the timer while true

        private void Start()
        {
            _wallet = GameApp.Wallet;

            // Subscribe wallet and screen events
            _wallet.Changed += screen.SetCoins;
            screen.SetCoins(_wallet.Coins);
            screen.BackClicked += GoHome;

            if (targetsView != null)
            {
                targetsView.TargetClicked += OnTargetClicked;
            }

            App.Analytics.LogEvent("main_open");
            BeginLevel();
        }

        private void OnDestroy()
        {
            if (_wallet != null) _wallet.Changed -= screen.SetCoins;
            if (screen != null) screen.BackClicked -= GoHome;
            if (targetsView != null) targetsView.TargetClicked -= OnTargetClicked;
        }

        private void Update()
        {
            if (_paused || _targetRules == null || _targetRules.IsFinished) return;

            // Advance timer without allocating memory
            _targetRules.UpdateTimer(Time.deltaTime);

            if (targetsView != null)
            {
                targetsView.UpdateDisplay(_targetRules.RemainingTargets, _targetRules.TimeRemaining);
            }

            // Check win/lose conditions evaluated by pure C# rules
            if (_targetRules.IsWon)
            {
                _targetRules.MarkFinished();
                if (targetsView != null) targetsView.SetInteractable(false);
                EndLevel(true);
            }
            else if (_targetRules.IsLost)
            {
                _targetRules.MarkFinished();
                if (targetsView != null) targetsView.SetInteractable(false);
                EndLevel(false);
            }
        }

        private void BeginLevel()
        {
            if (this == null) return;

            _session = new LevelSession(GameApp.Progress.CurrentLevel, App.Analytics, App.RemoteConfig);
            screen.SetLevel(_session.Level);
            _session.Start();

            // Setup real mini-game mechanic: 5 targets, 10 seconds countdown.
            // targetCount must equal the number of buttons assigned to TapTargetsView.targetButtons.
            _paused = false;
            int targetCount = 5;
            float timeLimit = 10f;
            _targetRules = new TapTargetsRules(targetCount, timeLimit);

            if (targetsView != null)
            {
                targetsView.ResetView(targetCount, timeLimit);
                targetsView.SetInteractable(true);
            }
        }

        // The view tells us WHICH button was tapped; the rules decide whether the hit counts.
        private void OnTargetClicked(int targetIndex)
        {
            if (_paused || _targetRules == null || _targetRules.IsFinished) return;

            if (_targetRules.RegisterTargetHit())
            {
                App.Haptic.Play(HapticType.Light);
                if (targetsView != null)
                {
                    targetsView.HideTarget(targetIndex);
                    targetsView.UpdateDisplay(_targetRules.RemainingTargets, _targetRules.TimeRemaining);
                }
            }
        }

        public void EndLevel(bool won)
        {
            var finished = _session?.Finish(won ? LevelOutcome.Won : LevelOutcome.Lost);
            if (finished == null) return;

            var result = finished.Value;

            if (result.Won)
            {
                _wallet.Add(result.Coins);
                GameApp.Progress.Advance();
            }

            App.Haptic.Play(result.Won ? HapticType.Success : HapticType.Failure);

            var dialog = App.Dialogs.Show<ResultDialog>();
            if (dialog != null) dialog.Setup(result, choice => OnResultChoice(choice, result));
            else BeginLevel();
        }

        private void OnResultChoice(ResultChoice choice, LevelResult result)
        {
            switch (choice)
            {
                case ResultChoice.Continue:
                    App.Ads.TryShowInterstitial(GameKeys.PlacementLevelEnd, BeginLevel);
                    break;
                case ResultChoice.DoubleReward:
                    App.Ads.ShowRewarded(GameKeys.PlacementDoubleReward, adResult => OnDoubleRewardResult(adResult, result));
                    break;
                case ResultChoice.Home:
                    GoHome();
                    break;
            }
        }

        private void OnDoubleRewardResult(RewardedResult adResult, LevelResult result)
        {
            if (adResult == RewardedResult.Rewarded)
            {
                _wallet.Add(result.Coins);
                App.Overlay.Toast($"+{result.Coins} bonus coins");
            }
            else if (adResult == RewardedResult.NotReady)
            {
                App.Overlay.Toast("Ad not available, try later");
            }

            BeginLevel();
        }

        private void GoHome()
        {
            App.Ads.TryShowInterstitial(GameKeys.PlacementMainBack, () => App.Scenes.Load(SceneId.Home));
        }
    }
}

1.4. Xóa đối tượng trên Scene & Gắn View trong Editor

  1. Mở scene Assets/_Project/Scenes/Main.unity.
  2. Trong cửa sổ Hierarchy, mở Canvas → MainScreen:
    • Chọn hai GameObject WinButton và LoseButton, bấm Delete để xóa bỏ.
  3. Tạo một GameObject mới đặt tên là TapTargetsView trực thuộc Canvas:
    • Gắn component TapTargetsView.
    • Tạo 2 TextMeshProUGUI (cho timerText và remainingText).
    • Tạo 5 Button làm mục tiêu (targets) trên màn hình và kéo vào mảng targetButtons.
  4. Chọn GameObject GameplayController trên Hierarchy, kéo component TapTargetsView vào slot targetsView trong Inspector.

1.5. Cách kiểm chứng trong Editor

  1. Bấm Play từ scene Loading (hoặc trực tiếp từ scene Main).
  2. Trường hợp Thắng: Nhanh tay click vào đủ 5 mục tiêu trước 10 giây.
    • Mỗi lần click: mục tiêu biến mất, có phản hồi haptic, số remaining giảm dần.
    • Khi chạm hết 5 mục tiêu: Ngay lập tức hiện ResultDialog thông báo "LEVEL 1 CLEAR! +50 coins".
  3. Trường hợp Thua: Không bấm gì và chờ cho đồng hồ đếm ngược hết 10 giây về 0.
    • Ngay khi hết giờ: Xuất hiện ResultDialog thông báo "LEVEL FAILED - Try again!", nút hiển thị chữ "RETRY".
💡 Bài tập mở rộng cho Bài 1

Hãy bổ sung logic cho TapTargetsView: mỗi khi bắt đầu một ván mới (ResetView), các nút mục tiêu sẽ được sinh ra ở các vị trí tọa độ ngẫu nhiên (random position) bên trong khung chữ nhật an toàn của màn hình. Đồng thời, hãy kết nối số lượng mục tiêu tăng dần theo cấp độ: int targetCount = 3 + _session.Level; để tạo độ thử thách tăng dần cho người chơi!

Bài 2: Thêm Dialog mới chuẩn chỉnh (PauseDialog)

📌 Mục tiêu bài tập

Tạo một popup tạm dừng ván chơi (PauseDialog) theo đúng chuẩn kiến trúc của template:

  • Kế thừa DialogBase.
  • Đặt prefab tại đúng đường dẫn quy ước: Assets/_Project/Resources/Dialogs/PauseDialog.prefab.
  • Root Prefab: RectTransform kéo giãn toàn màn hình (Stretch), kèm CanvasGroup và component PauseDialog; gán panel con vào field content để chạy hiệu ứng scale/fade.
  • Đăng ký sự kiện nút bấm trong OnOpened() và hủy đăng ký trong OnClosed().
  • Mở dialog từ controller thông qua facade: App.Dialogs.Show<PauseDialog>(). Lấy ResultDialog.cs làm quy chuẩn tham chiếu.

2.1. Vòng đời của một Dialog trong Template

sequenceDiagram
    autonumber
    actor Controller as GameplayController
    participant DS as DialogService (Core)
    participant Res as Resources/Dialogs/
    participant D as PauseDialog (Instance)

    Controller->>DS: App.Dialogs.Show<PauseDialog>()
    DS->>Res: Resources.Load("Dialogs/PauseDialog")
    DS->>D: Instantiate dưới Canvas [Dialogs]
    DS->>D: Open()
    D->>D: OnOpened() [AddListener các nút]
    D->>D: StartCoroutine Animate(0 -> 1)
    Note over D: Người chơi tương tác (Resume/Restart/Home)
    D->>D: Close()
    D->>D: StartCoroutine Animate(1 -> 0)
    D->>D: OnClosed() [RemoveListener các nút]
    D->>DS: Closed Event & Destroy GameObject
    D-->>Controller: Thực thi callback lựa chọn
          

2.2. Các file can thiệp

  • Assets/_Project/Game/UI/PauseDialog.cs (Tạo mới - mã nguồn component Dialog)
  • Assets/_Project/Resources/Dialogs/PauseDialog.prefab (Tạo mới - Prefab giao diện popup)
  • Assets/_Project/Game/UI/MainScreen.cs (Thêm nút Pause trên HUD)
  • Assets/_Project/Game/Gameplay/GameplayController.cs (Mở PauseDialog khi click nút Pause)

2.3. Mã nguồn hoàn chỉnh

C# Script: PauseDialog.cs

// File: Assets/_Project/Game/UI/PauseDialog.cs
using System;
using TapBrain.Core;
using UnityEngine;
using UnityEngine.UI;

namespace TapBrain.Game
{
    public enum PauseChoice
    {
        Resume,
        Restart,
        Home,
    }

    // In-game pause popup.
    // Prefab location: Assets/_Project/Resources/Dialogs/PauseDialog.prefab
    // Root requirements: Stretched RectTransform + CanvasGroup + PauseDialog component + content assigned.
    public sealed class PauseDialog : DialogBase
    {
        [SerializeField] private Button resumeButton;
        [SerializeField] private Button restartButton;
        [SerializeField] private Button homeButton;

        private Action<PauseChoice> _onChoice;

        // Call immediately after App.Dialogs.Show<PauseDialog>()
        public void Setup(Action<PauseChoice> onChoice)
        {
            _onChoice = onChoice;
        }

        protected override void OnOpened()
        {
            if (resumeButton != null) resumeButton.onClick.AddListener(OnResume);
            if (restartButton != null) restartButton.onClick.AddListener(OnRestart);
            if (homeButton != null) homeButton.onClick.AddListener(OnHome);
        }

        protected override void OnClosed()
        {
            if (resumeButton != null) resumeButton.onClick.RemoveListener(OnResume);
            if (restartButton != null) restartButton.onClick.RemoveListener(OnRestart);
            if (homeButton != null) homeButton.onClick.RemoveListener(OnHome);

            // Closed by the Android back button (closeOnBack = true) without a choice:
            // treat it as Resume, otherwise the game would stay paused forever.
            var pending = _onChoice;
            _onChoice = null;
            pending?.Invoke(PauseChoice.Resume);
        }

        private void OnResume() => Choose(PauseChoice.Resume);
        private void OnRestart() => Choose(PauseChoice.Restart);
        private void OnHome() => Choose(PauseChoice.Home);

        // Fires choice callback once, then animates out; blocks double-clicks
        private void Choose(PauseChoice choice)
        {
            if (IsClosing) return;

            var callback = _onChoice;
            _onChoice = null;
            Close();
            callback?.Invoke(choice);
        }
    }
}

Cách gọi mở Dialog từ GameplayController.cs:

// In GameplayController.cs
private void OnPauseClicked()
{
    var dialog = App.Dialogs.Show<PauseDialog>();
    if (dialog == null) return;

    // Freeze the round: Update() and OnTargetClicked() return early while _paused is true.
    // (Opening a dialog does NOT stop gameplay by itself.)
    _paused = true;

    dialog.Setup(choice =>
    {
        _paused = false;
        switch (choice)
        {
            case PauseChoice.Resume:
                break; // the timer continues from where it stopped
            case PauseChoice.Restart:
                BeginLevel();
                break;
            case PauseChoice.Home:
                GoHome();
                break;
        }
    });
}

2.4. Cấu trúc Prefab chuẩn trong Editor

Hãy tạo Prefab đúng theo cấu trúc phân cấp (Hierarchy) sau:

GameObject Component bắt buộc Thuộc tính cấu hình
PauseDialog (Root) - RectTransform
- CanvasGroup
- Image (Màu đen làm mờ nền)
- PauseDialog (Script)
- RectTransform: Anchor = Stretch/Stretch (Left=0, Top=0, Right=0, Bottom=0).
- Image: Color = rgba(0, 0, 0, 0.6).
- closeOnBack: true (cho phép bấm phím Back của Android để đóng).
└ Content (Panel con) - RectTransform
- Image (Nền hộp thoại popup)
- Size: 800 x 900, Anchor = Middle Center.
- Kéo GameObject này vào field content của PauseDialog.
└ └ TitleText TextMeshProUGUI Text: "PAUSED", Font Size: 64, Căn giữa.
└ └ ResumeButton Button Kéo vào slot resumeButton của PauseDialog.
└ └ RestartButton Button Kéo vào slot restartButton của PauseDialog.
└ └ HomeButton Button Kéo vào slot homeButton của PauseDialog.

Lưu đối tượng này thành Prefab tại: Assets/_Project/Resources/Dialogs/PauseDialog.prefab.

2.5. Cách kiểm chứng trong Editor

  1. Vào scene Main, bấm nút Pause trên HUD.
  2. Kiểm tra: Popup PauseDialog xuất hiện mượt mà với hiệu ứng phóng to dần (scale 0.9 → 1.0) và mờ sáng dần (fade in alpha 0 → 1) trong 0.15s.
  3. Bấm nút RESUME: Popup co nhỏ và biến mất; đối tượng trên Hierarchy tự động bị hủy sạch (Destroy).
  4. Bấm nút RESTART: Ván chơi bắt đầu lại từ đầu với thời gian và số target được reset.
  5. Bấm nút HOME: Game kích hoạt interstitial và chuyển cảnh về Home mượt mà.
💡 Bài tập mở rộng cho Bài 2

Thêm 2 Toggle âm thanh (Sound FX) và nhạc nền (Music) vào bên trong PauseDialog. Kết nối chúng trực tiếp với App.Audio.SoundOn và App.Audio.MusicOn để người chơi có thể bật/tắt tiếng ngay trong lúc đang tạm dừng ván chơi mà không cần thoát ra màn hình Settings ngoài menu chính.

Bài 3: Nút Rewarded Ad "+5 Lượt / +5 Giây" (Quảng cáo thưởng)

📌 Mục tiêu bài tập

Tạo tính năng hỗ trợ người chơi khẩn cấp khi sắp hết giờ: Nút xem quảng cáo nhận thưởng (Rewarded Ad) để cộng thêm +5 giây (hoặc +5 lượt đi).

  • Khai báo vị trí quảng cáo (placement const) chuẩn trong GameKeys.
  • Gọi hiển thị qua dịch vụ: App.Ads.ShowRewarded(placement, result => ...).
  • Tuân thủ hợp đồng nghiêm ngặt: CHỈ trao thưởng khi result == RewardedResult.Rewarded. Tuyệt đối không trao thưởng khi người chơi bấm bỏ qua (Skipped) hoặc quảng cáo lỗi (Failed).
  • Khi quảng cáo chưa sẵn sàng (RewardedResult.NotReady): Hiển thị thông báo Toast nổi trên màn hình bằng App.Overlay.Toast(...).

3.1. Các file can thiệp

  • Assets/_Project/Game/GameKeys.cs (Thêm hằng số placement)
  • Assets/_Project/Game/Gameplay/GameplayController.cs (Xử lý hiển thị ads và cộng thưởng)
  • Assets/_Project/Game/UI/MainScreen.cs (Thêm nút Extra Time trên HUD)

3.2. Mã nguồn hoàn chỉnh

Bước 1: Khai báo placement trong GameKeys.cs

// File: Assets/_Project/Game/GameKeys.cs
namespace TapBrain.Game
{
    public static class GameKeys
    {
        // ... Các key khác giữ nguyên ...

        // Ad placements (free text, reported to analytics / MAX dashboard)
        public const string PlacementLevelEnd = "level_end";
        public const string PlacementDoubleReward = "double_reward";
        public const string PlacementMainBack = "main_back";
        public const string PlacementAddMoves = "rewarded_add_moves"; // NEW PLACEMENT
    }
}

Bước 2: Xử lý hiển thị quảng cáo & cấp thưởng trong GameplayController.cs

// Inside GameplayController.cs
public void OnAddExtraTimeClicked()
{
    // Call Core Ads service via App facade
    App.Ads.ShowRewarded(GameKeys.PlacementAddMoves, adResult =>
    {
        // CONTRACT: Grant the reward ONLY on RewardedResult.Rewarded!
        if (adResult == RewardedResult.Rewarded)
        {
            if (_targetRules != null && !_targetRules.IsFinished)
            {
                // Add 5 seconds to timer
                _targetRules.AddTime(5f);

                // Sound & Haptic feedback
                App.Haptic.Play(HapticType.Success);

                // Show confirmation toast
                App.Overlay.Toast("+5 seconds added!");
            }
        }
        else if (adResult == RewardedResult.NotReady)
        {
            // Inform player to retry later
            App.Overlay.Toast("Ad not ready yet, please try again later!");
        }
        else if (adResult == RewardedResult.Skipped)
        {
            App.Overlay.Toast("Ad skipped. Reward not granted.");
        }
        else if (adResult == RewardedResult.Failed)
        {
            App.Overlay.Toast("Failed to load ad.");
        }
    });
}

3.3. Cách kiểm chứng trong Editor

  1. Bật Game view trong Unity Editor, chạy game vào scene Main.
  2. Khi đồng hồ đang đếm lùi còn 3 giây, click nút +5 Giây.
  3. Template gốc (chưa điền MAX key): ads bị tắt, callback trả về RewardedResult.NotReady → Toast "Ad not ready yet..." và không được cộng giờ. Đây là kết quả đúng: nó chứng minh nhánh NotReady hoạt động.
  4. Đã điền maxSdkKey + rewardedAdUnit thật: trong Editor, MAX hiện một quảng cáo giả để test. Xem hết rồi đóng → Rewarded, đồng hồ được cộng 5s và hiện Toast "+5 seconds added!". Đóng sớm → không có thưởng.
  5. Trên máy thật (development build), thử lại bằng quảng cáo test của mạng quảng cáo; xem thêm Mediation Debugger ở Chương 9.
💡 Bài tập mở rộng cho Bài 3

Tối ưu hóa trải nghiệm người dùng (UX): Thêm kiểm tra App.Ads.IsRewardedReady trước khi hiển thị nút. Nếu quảng cáo chưa tải xong về máy, hãy làm mờ nút (button.interactable = false) hoặc hiển thị icon xoay vòng chờ tải. Khi sự kiện quảng cáo sẵn sàng kích hoạt, tự động mở sáng nút trở lại.

Bài 4: Tích hợp gói mua trong ứng dụng IAP mới (Pack 500 Coins)

📌 Mục tiêu bài tập

Tích hợp trọn vẹn một sản phẩm IAP tiêu thụ (Consumable) mới vào game: Gói 500 Coins (com.tapbrain.template.coins_medium):

  • Khai báo sản phẩm vào IAP Catalog: Assets/Resources/IAPProductCatalog.json.
  • Định nghĩa hằng số sản phẩm trong GameKeys.
  • Thêm case trong hàm phân phối quà GameWallet.GrantProduct: Chỉ được return true sau khi nội dung đã được trao và LƯU BỀN VỮNG xuống đĩa.
  • Thực hiện lệnh mua qua App.Iap.Purchase(productId, onDone) và xử lý đầy đủ các trạng thái của PurchaseResult.

4.1. Quy trình giao dịch IAP và Hợp đồng phân phối quà (Grant Contract)

Rất nhiều lập trình viên mắc sai lầm nghiêm trọng: Cộng quà trực tiếp bên trong callback của lệnh Purchase. Hãy xem sơ đồ để hiểu tại sao template tách biệt thành PurchaseGrantHandler:

sequenceDiagram
    autonumber
    actor Player as Người chơi
    participant UI as Shop UI
    participant IAP as App.Iap (IIapService)
    participant Store as Google Play / App Store
    participant Wallet as GameWallet (Grant Handler)

    Player->>UI: Bấm "Mua 500 Coin"
    UI->>IAP: App.Iap.Purchase("coins_medium", callback)
    IAP->>Store: Yêu cầu xử lý thanh toán
    Store-->>IAP: Giao dịch thành công (Receipt hợp lệ)
    Note over IAP,Wallet: BƯỚC QUYẾT ĐỊNH (Không thể bỏ qua)
    IAP->>Wallet: GrantProduct("coins_medium")
    Wallet->>Wallet: 1. Cộng 500 Coins
2. LocalStorage.Save() [Lưu đĩa ngay!] Wallet-->>IAP: return true (Đã trao & đã lưu xong) IAP->>Store: ConfirmPurchase (Xác nhận với Store) IAP-->>UI: callback(PurchaseResult.Success) [Chỉ dùng để Toast feedback]

4.2. Các file can thiệp

  • Assets/Resources/IAPProductCatalog.json (Khai báo metadata sản phẩm)
  • Assets/_Project/Game/GameKeys.cs (Thêm const ProductCoinsMedium)
  • Assets/_Project/Game/Meta/GameWallet.cs (Thêm case trao thưởng trong GrantProduct)
  • Assets/_Project/Game/UI/HomeScreen.cs hoặc GameplayController.cs (Gọi hàm mua)

4.3. Mã nguồn hoàn chỉnh

Bước 1: Khai báo sản phẩm trong Assets/Resources/IAPProductCatalog.json

Thêm phần tử sản phẩm vào mảng "products" (chú ý: "type": 0 đại diện cho Consumable, "type": 1 là Non-Consumable):

{
  "id": "com.tapbrain.template.coins_medium",
  "type": 0,
  "storeIDs": [],
  "defaultDescription": {
    "googleLocale": 4,
    "title": "500 Coins",
    "description": "Medium consumable coins pack"
  },
  "screenshotPath": "",
  "applePriceTier": 2,
  "googlePrice": {
    "data": [0, 0, 0, 0],
    "num": 1.99
  },
  "pricingTemplateID": "",
  "descriptions": [],
  "payouts": []
}

Bước 2: Thêm hằng số vào GameKeys.cs

// File: Assets/_Project/Game/GameKeys.cs
public static class GameKeys
{
    // ...
    // IAP product ids (must match Assets/Resources/IAPProductCatalog.json)
    public const string ProductCoinsSmall = "com.tapbrain.template.coins_small";
    public const string ProductCoinsMedium = "com.tapbrain.template.coins_medium"; // NEW PRODUCT
}

Bước 3: Mở rộng GameWallet.GrantProduct

Tuân thủ tuyệt đối quy tắc: Cộng tiền → Ghi đĩa kiên cố (LocalStorage.Save()) → Trả về true. Nếu trả về false, Core sẽ giữ đơn hàng ở trạng thái chờ và thử giao lại ở lần mở game kế tiếp:

// File: Assets/_Project/Game/Meta/GameWallet.cs
public bool GrantProduct(string productId)
{
    switch (productId)
    {
        case GameKeys.ProductCoinsSmall:
            Add(100);
            return true;

        case GameKeys.ProductCoinsMedium:
            Add(500); // Add() calls LocalStorage.SetInt + LocalStorage.Save() immediately!
            return true;

        default:
            Debug.LogWarning($"[Game] Unknown product {productId}, not granted");
            return false;
    }
}

Bước 4: Viết lệnh mua và xử lý kết quả

// Example buy function inside your Shop UI or Controller
public void BuyMediumCoinPack()
{
    if (!App.Iap.IsReady)
    {
        App.Overlay.Toast("Store initializing, please wait...");
        return;
    }

    // Lock UI with a blocker to prevent multiple accidental taps
    App.Overlay.ShowBlocker();

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

        switch (result)
        {
            case PurchaseResult.Success:
                App.Overlay.Toast("Purchase successful! +500 coins added.");
                break;
            case PurchaseResult.Cancelled:
                App.Overlay.Toast("Purchase cancelled.");
                break;
            case PurchaseResult.Pending:
                App.Overlay.Toast("Purchase pending authorization.");
                break;
            case PurchaseResult.NotReady:
                App.Overlay.Toast("Store service is not available.");
                break;
            case PurchaseResult.Failed:
                App.Overlay.Toast("Transaction failed.");
                break;
        }
    });
}

4.4. Cách kiểm chứng trong Editor

  1. Chạy game từ scene Loading.
  2. Khi vào màn hình có nút mua gói 500 Coin, click nút mua.
  3. UnityPurchasing Fake Store xuất hiện bảng xác nhận mua hàng trong Editor:
    • Bấm nút "Buy": Giả lập thanh toán thành công. Kiểm tra số Coin trên HUD lập tức nhảy tăng thêm +500. Kiểm tra Console thấy log "Purchase successful".
    • Dừng Play Mode rồi bật lại: Số coin mới vẫn được lưu nguyên vẹn (chứng minh đã lưu đĩa thành công).
    • Bấm "Cancel": Toast hiển thị "Purchase cancelled", số coin giữ nguyên.
💡 Bài tập mở rộng cho Bài 4

Hãy hiển thị giá tiền bản địa hóa thật (Localized Price) lên nhãn của nút mua thay vì ghi cứng giá "$1.99". Sử dụng hàm App.Iap.GetLocalizedPrice(GameKeys.ProductCoinsMedium). Nếu store chưa sẵn sàng hoặc trả về rỗng, hãy hiển thị giá fallback mặc định.

Bài 5: Thêm Remote Config Key để cân bằng game động

📌 Mục tiêu bài tập

Đưa thông số thời gian của màn chơi (game_level_time_limit_sec) lên Firebase Remote Config để bộ phận Game Designer có thể điều chỉnh độ khó từ xa mà không cần build bản cập nhật client mới:

  • Định nghĩa hằng số key trong GameKeys.
  • Đăng ký giá trị mặc định ngoại tuyến (offline default) trong GameEntry.Init() qua App.RemoteConfig.AddDefault(key, "value").
  • Đọc giá trị kiểu số nguyên / số thực an toàn bằng GetLong / GetDouble / GetBool.
  • Lắng nghe sự kiện App.RemoteConfig.Updated để cập nhật lại thông số ngay khi Firebase đồng bộ dữ liệu mới từ máy chủ về.

5.1. Các file can thiệp

  • Assets/_Project/Game/GameKeys.cs (Thêm const key Remote Config)
  • Assets/_Project/Game/GameEntry.cs (Đăng ký giá trị default ban đầu)
  • Assets/_Project/Game/Gameplay/GameplayController.cs (Đọc giá trị và lắng nghe sự kiện Updated)

5.2. Mã nguồn hoàn chỉnh

Bước 1: Khai báo key trong GameKeys.cs

// File: Assets/_Project/Game/GameKeys.cs
public static class GameKeys
{
    // ...
    // Remote Config keys
    public const string RcWinCoins = "game_win_coins";
    public const string RcLevelTimeLimit = "game_level_time_limit_sec"; // NEW RC KEY
}

Bước 2: Đăng ký giá trị mặc định ngoại tuyến trong GameEntry.cs

Giá trị này sẽ được trả về ngay lập tức khi game chưa kết nối được mạng hoặc Firebase đang trong quá trình tải:

// File: Assets/_Project/Game/GameEntry.cs
// Inside GameEntry.Init():
// 3. Remote Config defaults for game keys (used offline / before the first fetch)
App.RemoteConfig.AddDefault(GameKeys.RcWinCoins, "50");
App.RemoteConfig.AddDefault(GameKeys.RcLevelTimeLimit, "12"); // Default 12 seconds

Bước 3: Đọc giá trị và đăng ký lắng nghe sự kiện trong GameplayController.cs

// Inside GameplayController.cs
private void Start()
{
    // ...
    // Subscribe to Remote Config updates fetched in background
    App.RemoteConfig.Updated += OnRemoteConfigUpdated;
}

private void OnDestroy()
{
    // ...
    App.RemoteConfig.Updated -= OnRemoteConfigUpdated;
}

// Full BeginLevel from Bài 1: only the timeLimit line changes.
private void BeginLevel()
{
    if (this == null) return;

    _session = new LevelSession(GameApp.Progress.CurrentLevel, App.Analytics, App.RemoteConfig);
    screen.SetLevel(_session.Level);
    _session.Start();

    _paused = false;
    int targetCount = 5;
    // Read typed long value with a safe fallback (used offline / before the first fetch)
    float timeLimit = App.RemoteConfig.GetLong(GameKeys.RcLevelTimeLimit, 12);
    _targetRules = new TapTargetsRules(targetCount, timeLimit);

    if (targetsView != null)
    {
        targetsView.ResetView(targetCount, timeLimit);
        targetsView.SetInteractable(true);
    }
}

// Fired when Firebase Remote Config completes background fetch & activate
private void OnRemoteConfigUpdated()
{
    Debug.Log($"[Game] RemoteConfig updated from server. New time limit: {App.RemoteConfig.GetLong(GameKeys.RcLevelTimeLimit, 12)}s");
}

5.3. Cách kiểm chứng trong Editor

  1. Khởi động game ở chế độ ngắt mạng (offline) → Thời gian của ván chơi hiển thị đúng 12 giây theo giá trị mặc định đã đăng ký trong GameEntry.
  2. Thử sửa tạm thời giá trị default trong GameEntry.cs thành "20" → Vào ván chơi thấy thời gian khởi đầu đổi thành 20 giây.
  3. Khi kết nối Firebase thật: Vào Firebase Console → Remote Config → Thêm parameter game_level_time_limit_sec = 25 → Bấm Publish. Khi app fetch xong, console xuất hiện log RemoteConfig updated và các màn chơi sau sẽ áp dụng mốc 25 giây.
💡 Bài tập mở rộng cho Bài 5

Thêm một key boolean game_double_reward_enabled với giá trị mặc định là "true". Sử dụng App.RemoteConfig.GetBool(...) trong ResultDialog để có thể bật hoặc tắt nút "X2 Reward via Ads" từ máy chủ trong các chiến dịch đặc biệt mà không cần sửa code game.

Bài 6: Thêm Game Service vào Meta và mở rộng Facade GameApp

📌 Mục tiêu bài tập

Xây dựng một dịch vụ vòng đời ứng dụng mới (App-lifetime Game Service): DailyRewardService (Quản lý điểm danh nhận thưởng hàng ngày):

  • Tạo class thuần C# (Plain C#) đặt tại thư mục Assets/_Project/Game/Meta/DailyRewardService.cs.
  • Lưu trữ bền vững ngày nhận thưởng gần nhất thông qua LocalStorage với tiền tố game..
  • Đăng ký service vào hệ thống tại GameEntry.Init() thông qua ServiceLocator.Register(...).
  • Tạo property truy cập tĩnh trên Facade GameApp.cs để toàn bộ game có thể truy cập tiện lợi: GameApp.DailyReward.
  • Tuân thủ quy tắc R3 & R4: Không dùng MonoBehaviour, không tạo Singleton Instance tĩnh, không tạo GameObject rác.

6.1. Các file can thiệp

  • Assets/_Project/Game/GameKeys.cs (Thêm storage keys)
  • Assets/_Project/Game/Meta/DailyRewardService.cs (Tạo mới Service)
  • Assets/_Project/Game/GameEntry.cs (Đăng ký vào ServiceLocator)
  • Assets/_Project/Game/GameApp.cs (Thêm forwarder property vào Facade)

6.2. Mã nguồn hoàn chỉnh

Bước 1: Khai báo LocalStorage key trong GameKeys.cs

// File: Assets/_Project/Game/GameKeys.cs
public static class GameKeys
{
    // ...
    // LocalStorage keys for Meta services (prefixed with "game.")
    public const string DailyRewardLastClaim = "game.daily_reward_last_claim";
    public const string DailyRewardStreak = "game.daily_reward_streak";
}

Bước 2: Viết lớp logic thuần C# DailyRewardService.cs

// File: Assets/_Project/Game/Meta/DailyRewardService.cs
using System;
using System.Globalization;
using TapBrain.Core;

namespace TapBrain.Game
{
    // App-lifetime meta service managing daily login rewards.
    // Plain C# class: no MonoBehaviour, persisted via LocalStorage.
    public sealed class DailyRewardService
    {
        private const string DateFormat = "yyyy-MM-dd";
        private const int BaseRewardCoins = 100;

        public event Action<int> RewardClaimed;

        public int CurrentStreak => LocalStorage.GetInt(GameKeys.DailyRewardStreak, 0);

        // Checks if current UTC day is strictly after the last claimed date
        public bool CanClaimToday()
        {
            string lastClaimDateStr = LocalStorage.GetString(GameKeys.DailyRewardLastClaim, string.Empty);
            if (string.IsNullOrEmpty(lastClaimDateStr)) return true;

            if (DateTime.TryParseExact(lastClaimDateStr, DateFormat, CultureInfo.InvariantCulture, DateTimeStyles.None, out var lastDate))
            {
                return DateTime.UtcNow.Date > lastDate.Date;
            }

            return true;
        }

        // Claims reward, adds coins to wallet, persists state, and returns true
        public bool TryClaimReward(out int grantedCoins)
        {
            grantedCoins = 0;
            if (!CanClaimToday()) return false;

            int newStreak = CurrentStreak + 1;
            grantedCoins = BaseRewardCoins * (newStreak > 5 ? 5 : newStreak);

            // Add coins directly to wallet
            GameApp.Wallet.Add(grantedCoins);

            // Save claim date and updated streak
            LocalStorage.SetString(GameKeys.DailyRewardLastClaim, DateTime.UtcNow.ToString(DateFormat, CultureInfo.InvariantCulture));
            LocalStorage.SetInt(GameKeys.DailyRewardStreak, newStreak);
            LocalStorage.Save();

            RewardClaimed?.Invoke(grantedCoins);
            return true;
        }
    }
}

Bước 3: Đăng ký Service tại điểm khởi đầu GameEntry.cs

// File: Assets/_Project/Game/GameEntry.cs
// Inside GameEntry.Init():
// 1. Game services (plain C#, live for the whole app session)
var wallet = new GameWallet();
ServiceLocator.Register(wallet);
ServiceLocator.Register(new LevelProgress());
ServiceLocator.Register(new DailyRewardService()); // REGISTER DAILY REWARD SERVICE

Bước 4: Mở rộng Facade GameApp.cs

// File: Assets/_Project/Game/GameApp.cs
using TapBrain.Core;

namespace TapBrain.Game
{
    public static class GameApp
    {
        public static GameWallet Wallet => ServiceLocator.Get<GameWallet>();
        public static LevelProgress Progress => ServiceLocator.Get<LevelProgress>();
        public static DailyRewardService DailyReward => ServiceLocator.Get<DailyRewardService>(); // FORWARDER
    }
}

Sử dụng ở bất cứ đâu trong Game UI (ví dụ trong HomeScreen.cs):

// In HomeScreen.cs
private void CheckDailyBonus()
{
    if (GameApp.DailyReward.CanClaimToday())
    {
        if (GameApp.DailyReward.TryClaimReward(out int coins))
        {
            App.Overlay.Toast($"Daily Reward claimed! +{coins} coins");
        }
    }
}

6.3. Cách kiểm chứng trong Editor

  1. Bật Play Mode và kiểm tra GameApp.DailyReward.CanClaimToday() trả về true.
  2. Thực hiện lệnh TryClaimReward() → Số coin của Wallet lập tức nhảy tăng lên 100, Toast hiện chúc mừng.
  3. Gọi CanClaimToday() lần thứ hai ngay sau đó → Trả về false (không thể nhận trùng trong cùng một ngày).
  4. Tắt Play Mode, bật lại: Trạng thái vẫn là false vì ngày nhận đã được lưu kiên cố trong LocalStorage.
💡 Bài tập mở rộng cho Bài 6

Xây dựng thêm InventoryService.cs để quản lý các vật phẩm bổ trợ (Booster: Freeze Time, Hint, Hammer). Lưu trữ số lượng mỗi booster trong LocalStorage, cung cấp các hàm int GetCount(string boosterId), void Add(string boosterId, int amount), bool TryUse(string boosterId) và expose qua GameApp.Inventory.

Bài 7: Ghi nhận sự kiện phân tích tùy chỉnh (Custom Analytics Event)

📌 Mục tiêu bài tập

Ghi nhận hành vi chi tiết của người chơi trong màn chơi bằng Custom Analytics Event kèm Dictionary tham số thông qua App.Analytics.LogEvent(name, parameters):

  • Nắm rõ danh sách các kiểu dữ liệu tham số hợp lệ được hệ thống chấp nhận theo đặc tả tại AGENT_GUIDE.md §4.
  • Hiểu cơ chế điều phối quạt ra (Fan-out Hub) của AnalyticsService: một lệnh gọi sẽ tự động bắn đồng thời tới Firebase Analytics, GameAnalytics và AppMetrica.
  • Nắm vững điểm khác biệt khi truyền tham số cho GameAnalytics (sử dụng parameter "value" cho Design Event).

7.1. Bảng kiểu dữ liệu tham số được phép (Allowed Parameter Types)

Theo chuẩn thiết kế của FirebaseAnalyticsSink.cs và quy định của Google Firebase / GameAnalytics, hàm LogEvent chỉ chấp nhận các kiểu giá trị sau trong Dictionary:

Kiểu dữ liệu C# Xử lý nội bộ trong Sink Ví dụ thực tế
int Chuyển đổi thành tham số số nguyên 64-bit (long) { "target_id", 3 }
long Giữ nguyên kiểu số nguyên 64-bit { "level", (long)GameApp.Progress.CurrentLevel }
float Chuyển đổi thành số thực { "time_remaining", 8.45f }
double Giữ nguyên số thực độ chính xác kép { "value", 10.5 }
bool Tự động quy đổi: true → 1L, false → 0L { "is_perfect", true }
string Chuỗi văn bản (UTF-8) { "booster_type", "freeze_time" }
⚠️ Lưu ý đặc biệt đối với GameAnalytics

Dịch vụ GameAnalyticsSink coi mọi Custom Event là một Design Event. Nếu Dictionary có chứa tham số với key mang tên chính xác là "value" (kiểu double hoặc float), giá trị này sẽ được trích xuất làm metric định lượng chính trên Dashboard của GameAnalytics.

7.2. Các file can thiệp

  • Assets/_Project/Game/Gameplay/GameplayController.cs hoặc TapTargetsRules.cs

7.3. Mã nguồn hoàn chỉnh

// File: Assets/_Project/Game/Gameplay/GameplayController.cs
using System.Collections.Generic;
using TapBrain.Core;
using UnityEngine;

// Inside GameplayController.cs:
// Log a rich telemetry event when a target is successfully tapped
private void LogTargetTappedAnalytics(int targetId, float remainingTime, bool isFirstHit)
{
    // Dictionary containing ONLY allowed types: int, long, float, double, bool (0/1), string
    var eventParams = new Dictionary<string, object>
    {
        { "level", (long)GameApp.Progress.CurrentLevel },
        { "target_id", targetId },
        { "time_left_sec", (double)remainingTime },
        { "is_first_hit", isFirstHit },
        { "input_mode", "touch" },
        { "value", 1.0 } // Metric value used by GameAnalytics design event
    };

    App.Analytics.LogEvent("target_tap_success", eventParams);
}

// Log when player runs out of time
private void LogTimeoutAnalytics(int targetsRemaining, float totalDuration)
{
    var eventParams = new Dictionary<string, object>
    {
        { "level", (long)GameApp.Progress.CurrentLevel },
        { "targets_unhit", targetsRemaining },
        { "duration_spent", (double)totalDuration },
        { "reason", "timer_expired" }
    };

    App.Analytics.LogEvent("level_timeout_fail", eventParams);
}

7.4. Cách kiểm chứng trong Editor

  1. Bật cửa sổ Console trong Unity Editor.
  2. Chơi game và click vào các mục tiêu.
  3. Kiểm tra Console: Nhờ dòng CoreLog.Info("Analytics", eventName) tích hợp sẵn trong AnalyticsService, mọi sự kiện bắn ra đều in log rõ ràng dạng:
    [Analytics] target_tap_success
  4. Khi build bản Development lên điện thoại Android hoặc iOS: Mở Firebase Console → Analytics → DebugView để quan sát thời gian thực từng sự kiện kèm đầy đủ các cặp key-value tham số vừa gửi lên.
💡 Bài tập mở rộng cho Bài 7

Thiết lập phễu phân tích người chơi mới (Onboarding Funnel): Ghi nhận sự kiện tutorial_step tại mỗi bước người chơi hoàn thành trong ván đầu tiên, bao gồm các tham số step_id (int), step_name (string), và duration_sec (float). Giúp đội ngũ vận hành đo lường chính xác tỷ lệ rớt người chơi (drop-off rate) ở từng bước hướng dẫn.

Tổng kết bài học & Bước tiếp theo

Chúc mừng bạn đã hoàn thành xuất sắc toàn bộ 7 bài tập thực hành cốt lõi! Giờ đây bạn đã nắm vững:

  • Cách xây dựng cơ chế gameplay tách biệt hoàn toàn giữa View và Logic (zero allocation).
  • Cách tạo và quản lý popup kế thừa DialogBase mượt mà với App.Dialogs.
  • Cách kiếm tiền thông minh bằng Rewarded Ads với hợp đồng trao thưởng an toàn.
  • Cách mở rộng danh mục IAP và quy trình phân phối sản phẩm bền vững qua GameWallet.
  • Cách cân bằng game từ xa bằng Firebase Remote Config.
  • Cách mở rộng kiến trúc bằng các Game Service thuần C# và Facade GameApp.
  • Cách đo đạc số liệu người chơi bằng Custom Analytics Event.