Báo Cáo Kiến Trúc Chuyên Sâu: Xây Dựng Hệ Thống Game Card Battle (Hearthstone-Style)
1. Tổng quan Điều hành và Phạm vi Kiến trúc
Trong bối cảnh phát triển game hiện đại trên Unity, đặc biệt là với thể loại Collectible Card Game (CCG) có độ phức tạp cao về logic và quản lý trạng thái như Hearthstone, việc sử dụng các mô hình lập trình truyền thống (như Monolithic MonoBehaviour, Coroutines, và Singleton) đang dần trở nên lỗi thời và khó bảo trì. Báo cáo này cung cấp một bản thiết kế kiến trúc toàn diện, chi tiết và có khả năng mở rộng cao, dựa trên "Modern Unity Stack" bao gồm ba trụ cột công nghệ chính: VContainer (Dependency Injection), UniTask (Asynchronous Logic), và R3 (Reactive Extensions).
Mục tiêu của báo cáo là cung cấp một lộ trình kỹ thuật chi tiết (technical roadmap) và một template mã nguồn (code template) để xây dựng hạ tầng cốt lõi của game. Chúng ta sẽ không chỉ dừng lại ở việc ghép nối các thư viện, mà sẽ đi sâu phân tích cách thức chúng tương tác để giải quyết các vấn đề kinh điển của dòng game thẻ bài: quản lý lượt chơi (turn management), xử lý chuỗi hiệu ứng (effect chaining), đồng bộ hóa hoạt ảnh (animation syncing) và phản hồi giao diện người dùng (reactive UI).
1.1 Tại sao lại là Modern Unity Stack?
Sự chuyển dịch sang stack công nghệ mới này không phải là trào lưu nhất thời, mà là sự phản hồi trực tiếp đối với những hạn chế về hiệu năng và khả năng kiểm thử của Unity truyền thống.
Bảng 1.1: So sánh Mô hình Truyền thống và Modern Unity Stack
| Tiêu chí | Unity Truyền thống (Classic) | Modern Unity Stack (VContainer + UniTask + R3) | Lợi ích Cốt lõi cho Game Thẻ Bài |
|---|---|---|---|
| Quản lý Phụ thuộc | Singleton, GetComponent, FindObjectOfType | Dependency Injection (DI) qua VContainer | Giảm sự phụ thuộc chặt chẽ (Decoupling), dễ dàng Unit Test logic thẻ bài độc lập. |
| Xử lý Bất đồng bộ | Coroutines (IEnumerator, yield return) | async/await với UniTask (Zero Allocation) | Code tuần tự dễ đọc cho các Phase của lượt đấu, tránh rác bộ nhớ (GC Alloc). |
| Quản lý Sự kiện | C# Events, UnityEvents, Polling trong Update() | Reactive Streams (R3), Observable Collections | UI cập nhật tự động theo Data (MVVM), xử lý chuỗi sự kiện phức tạp (Chain Reaction). |
| Kiến trúc Dữ liệu | MonoBehaviour chứa logic và data | Pure C# Classes (Logic) + ScriptableObject (Config) | Tách biệt hoàn toàn Logic và View, cho phép chạy giả lập server-side nếu cần. |
1.2 Phân tích các thành phần công nghệ
-
VContainer: Là thư viện DI (Dependency Injection) nhanh nhất hiện nay cho Unity, vượt trội so với Zenject về thời gian khởi động và mức tiêu thụ bộ nhớ. Trong game thẻ bài, VContainer đóng vai trò là "xương sống", quản lý vòng đời của các hệ thống như
TurnManager,BoardManager,CardFactory. Nó cho phép định nghĩa các Scope (phạm vi) rõ ràng: Global Scope cho Network/Auth, và Scene Scope cho từng trận đấu. -
UniTask: Thay thế hoàn toàn Coroutine. Game thẻ bài bản chất là một State Machine khổng lồ diễn ra theo tuần tự (Rút bài -> Chờ đánh bài -> Xử lý hiệu ứng -> Kết thúc lượt). UniTask cho phép viết logic này dưới dạng code tuyến tính, dễ đọc, đồng thời hỗ trợ
CancellationTokensđể xử lý việc người chơi thoát game giữa chừng một cách an toàn. -
R3 (Reactive Extensions for C# v3): Phiên bản mới nhất thay thế UniRx. R3 giải quyết vấn đề quản lý trạng thái (State Management). Khi HP của một Minion thay đổi, UI, Animation System, và Game Log cần phản ứng ngay lập tức. R3 cung cấp
ReactivePropertyđể liên kết dữ liệu này mà không cần các class gọi nhau trực tiếp, tuân thủ nguyên lý Inversion of Control.
2. Thiết kế Kiến trúc Tầng (Layered Architecture)
Để đảm bảo tính bảo trì cho dự án quy mô lớn (15,000+ dòng code logic), chúng ta sẽ áp dụng mô hình kiến trúc phân tầng, kết hợp với Domain-Driven Design (DDD) nhẹ.
2.1 Sơ đồ Phân tầng Hệ thống
Hệ thống được chia thành 4 tầng chính, giao tiếp với nhau thông qua Interfaces và Reactive Streams.
-
Presentation Layer (View): Chứa các
MonoBehaviour, Prefabs, UI Elements. Nhiệm vụ duy nhất là hiển thị dữ liệu và nhận Input từ người chơi. Tầng này "ngu ngốc" (passive), không chứa logic game. -
Application Layer (Controller/Presenter): Điều phối luồng game. Sử dụng UniTask để quản lý trình tự các hành động (Sequence). Nó nhận Input từ View, gọi Logic ở Domain, và cập nhật View.
-
Domain Layer (Model): Chứa logic cốt lõi của game thẻ bài (Rules, State, Card Effects). Đây là các Pure C# Classes (POCO), hoàn toàn không phụ thuộc vào
UnityEngine.dllnếu có thể, giúp dễ dàng viết Unit Test. -
Infrastructure Layer: Xử lý việc load Asset (Addressables), Networking, Lưu trữ cục bộ.
2.2 Cấu trúc Thư mục và Assembly Definition (Asmdef)
Việc chia tách Assembly là bắt buộc để giảm thời gian biên dịch và ngăn chặn sự phụ thuộc vòng (Circular Dependency).
-
Game.Core.dll: Chứa các Interface, Data Models, Reactive Properties (R3). Không phụ thuộc vào Unity Engine (hoặc rất ít). -
Game.Logic.dll: ChứaTurnManager,CardSystem,EffectResolver. Phụ thuộc vàoGame.Core. -
Game.View.dll: Chứa cácMonoBehaviour,CardView,HandView. Phụ thuộc vàoGame.LogicvàGame.Core. -
Game.App.dll: ChứaLifetimeScope(VContainer), Entry Points. Phụ thuộc vào tất cả các module trên để thực hiện việc ghép nối (wiring).
3. Triển khai Chi tiết: VContainer làm Composition Root
Đây là phần quan trọng nhất để khởi tạo "thế giới" của game. Chúng ta sẽ xây dựng một GameLifetimeScope để đăng ký tất cả các dịch vụ.
3.1 Thiết lập GameLifetimeScope
Trong Hearthstone, chúng ta có các hệ thống tồn tại suốt quá trình chơi (Global) và các hệ thống chỉ tồn tại trong một ván đấu (Match).
// File: Game/App/Scopes/MatchLifetimeScope.cs
using VContainer;
using VContainer.Unity;
using Game.Logic;
using Game.View;
using R3;
public class MatchLifetimeScope : LifetimeScope
{
// Cấu hình Game (ScriptableObject) được kéo thả vào Inspector
private MatchConfiguration _matchConfig;
// Prefab Factories
private CardView _cardViewPrefab;
protected override void Configure(IContainerBuilder builder)
{
// 1. Đăng ký Configuration (Data)
builder.RegisterInstance(_matchConfig);
// 2. Đăng ký Domain Logic (Model & Systems)
// TurnManager quản lý vòng lặp lượt chơi
builder.Register<TurnManager>(Lifetime.Scoped);
// BoardManager quản lý trạng thái bàn cờ (Zone, Minions)
builder.Register<BoardManager>(Lifetime.Scoped);
// PlayerSystem quản lý HP, Mana của người chơi
builder.Register<PlayerSystem>(Lifetime.Scoped);
// CardSystem xử lý logic bài
builder.Register<CardExecutionSystem>(Lifetime.Scoped);
// 3. Đăng ký Entry Points (Các class điều khiển luồng chính)
// GameLoopPresenter sẽ tự động chạy Start() khi scene load nhờ interface IAsyncStartable
builder.RegisterEntryPoint<GameLoopPresenter>();
// 4. Đăng ký UI/View Presenters
// Tự động inject dependency vào các MonoBehaviour đã có trên scene
builder.RegisterComponentInHierarchy<GameplayUIManager>();
builder.RegisterComponentInHierarchy<BoardView>();
// 5. Đăng ký Factories (Dùng để tạo CardView động)
builder.RegisterFactory<CardData, CardView>(container =>
{
return (data) =>
{
var view = Instantiate(_cardViewPrefab);
// Inject thủ công hoặc dùng container.Inject(view) nếu view có [Inject]
view.Initialize(data);
return view;
};
}, Lifetime.Scoped);
// 6. Đăng ký Event Bus (MessagePipe hoặc R3 Subject)
// Dùng Subject để broadcast các sự kiện global như "MinionDied", "SpellCast"
builder.RegisterInstance(new Subject<GameEvent>());
}
}
Phân tích chuyên sâu:
-
RegisterEntryPoint<GameLoopPresenter>: Đây là tính năng mạnh mẽ của VContainer. ClassGameLoopPresentersẽ implementIAsyncStartable. VContainer sẽ tự động gọi methodStartAsynccủa nó ngay khi container được build xong. Điều này loại bỏ hoàn toàn việc dùngStart()trong MonoBehaviour để khởi động logic game, giúp kiểm soát thứ tự khởi tạo tuyệt đối. -
RegisterFactory: Game thẻ bài yêu cầu sinh ra đối tượng liên tục (Draw card, Summon minion). Factory pattern giúp tạo ra các đối tượng này mà vẫn đảm bảo chúng nhận được các dependency cần thiết (ví dụ: CardView cần biết về BoardManager để xử lý Drag & Drop).
4. Quản lý Vòng lặp Game Bất đồng bộ với UniTask
Logic của một game thẻ bài theo lượt (Turn-based) là một chuỗi các trạng thái tuần tự. Sử dụng State Pattern truyền thống thường dẫn đến việc phân mảnh logic ra quá nhiều class con (PlayerTurnState, EnemyTurnState, AnimationState). Với UniTask, ta có thể viết logic này như một "câu chuyện" tuyến tính.
4.1 GameLoopPresenter: Trái tim của trận đấu
// File: Game/Logic/Presenters/GameLoopPresenter.cs
using Cysharp.Threading.Tasks;
using VContainer.Unity;
using System.Threading;
using UnityEngine;
public class GameLoopPresenter : IAsyncStartable
{
private readonly TurnManager _turnManager;
private readonly BoardManager _boardManager;
private readonly GameplayUIManager _uiManager;
public GameLoopPresenter(
TurnManager turnManager,
BoardManager boardManager,
GameplayUIManager uiManager)
{
_turnManager = turnManager;
_boardManager = boardManager;
_uiManager = uiManager;
}
public async UniTask StartAsync(CancellationToken cancellation)
{
// 1. Setup ban đầu (Chia bài, animation mở màn)
await _uiManager.ShowOpeningAnimationAsync(cancellation);
await _boardManager.InitializeDeckAndHandAsync(cancellation);
// 2. Vòng lặp chính của game
while (!cancellation.IsCancellationRequested)
{
// Bắt đầu lượt mới
await _turnManager.StartTurnAsync(cancellation);
// Chờ người chơi (hoặc AI) thực hiện hành động
// Đây là điểm mấu chốt: Hàm này sẽ treo (await) cho đến khi nút "End Turn" được bấm
// hoặc thời gian hết.
await _turnManager.WaitForTurnResolutionAsync(cancellation);
// Kết thúc lượt (Xử lý hiệu ứng cuối lượt, đốt dây...)
await _turnManager.EndTurnAsync(cancellation);
// Kiểm tra điều kiện thắng thua
if (_boardManager.CheckWinCondition(out var winner))
{
await _uiManager.ShowWinScreenAsync(winner, cancellation);
break; // Thoát vòng lặp
}
}
}
}
4.2 Kỹ thuật "WaitUntil" cho Input người chơi
Trong TurnManager, làm thế nào để chờ người chơi đánh bài mà không dùng Update()? UniTask cung cấp giải pháp tuyệt vời.
// File: Game/Logic/TurnManager.cs
public class TurnManager
{
private readonly ReactiveProperty<bool> _isTurnActive = new(false);
private readonly Subject<Unit> _endTurnSignal = new();
public async UniTask StartTurnAsync(CancellationToken token)
{
// Logic: Tăng Mana, Rút bài
_playerSystem.RefreshMana();
await _cardSystem.DrawCardAsync(token); // Await animation rút bài
_isTurnActive.Value = true;
}
public async UniTask WaitForTurnResolutionAsync(CancellationToken token)
{
// Chờ cho đến khi sự kiện EndTurn được bắn ra (từ nút bấm UI)
// Hoặc hết giờ (Timer)
var endTurnTask = _endTurnSignal.FirstAsync(token).AsUniTask();
var timerTask = UniTask.Delay(TimeSpan.FromSeconds(60), cancellationToken: token);
// Chờ cái nào đến trước (Race)
await UniTask.WhenAny(endTurnTask, timerTask);
_isTurnActive.Value = false;
}
public void TriggerEndTurn()
{
if (_isTurnActive.Value)
_endTurnSignal.OnNext(Unit.Default);
}
}
Insight Kiến trúc: Việc sử dụng UniTask.WhenAny cho phép xử lý Timer và Input người chơi cùng lúc một cách thanh lịch. Nếu Timer hết giờ trước, nó tự động hủy việc chờ Input và ép buộc kết thúc lượt. Đây là logic rất khó triển khai sạch sẽ nếu dùng Coroutines.
5. Hệ thống Dữ liệu Phản ứng (Reactive Data) với R3
Game thẻ bài là một hệ thống quản lý trạng thái phức tạp (State-heavy). Ví dụ: Một lá bài có Attack, Health, ManaCost. Các giá trị này có thể bị thay đổi bởi Buff/Debuff.
5.1 Mô hình hóa dữ liệu (Entity - Model)
Chúng ta không dùng Monobehaviour để lưu dữ liệu. Thay vào đó, ta dùng Pure C# Class với các ReactiveProperty.
// File: Game/Core/Models/CardEntity.cs
using R3;
public class CardEntity
{
// Dữ liệu tĩnh (Config)
public CardData Data { get; private set; }
// Dữ liệu động (State) - Sử dụng R3 ReactiveProperty
public ReactiveProperty<int> CurrentHealth { get; }
public ReactiveProperty<int> CurrentAttack { get; }
public ReactiveProperty<int> CurrentManaCost { get; }
// Trạng thái logic
public ReactiveProperty<bool> IsTaunt { get; }
public ReactiveProperty<bool> IsDivineShield { get; }
public CardEntity(CardData data)
{
Data = data;
// Khởi tạo giá trị ban đầu từ Config
CurrentHealth = new ReactiveProperty<int>(data.BaseHealth);
CurrentAttack = new ReactiveProperty<int>(data.BaseAttack);
CurrentManaCost = new ReactiveProperty<int>(data.BaseManaCost);
IsTaunt = new ReactiveProperty<bool>(data.HasTaunt);
}
public void TakeDamage(int amount)
{
if (IsDivineShield.Value)
{
IsDivineShield.Value = false;
return;
}
CurrentHealth.Value -= amount;
}
}
5.2 Mô hình MVVM (Model-View-ViewModel) cho CardView
CardView (MonoBehaviour) sẽ lắng nghe thay đổi từ CardEntity (Model) và cập nhật UI. Đây là mô hình Passive View.
// File: Game/View/CardView.cs
using UnityEngine;
using TMPro;
using R3;
using VContainer;
public class CardView : MonoBehaviour
{
private TextMeshPro _healthText;
private TextMeshPro _attackText;
private Renderer _artRenderer;
private CardEntity _model;
// Hàm này được gọi bởi Factory sau khi Instantiate
public void Initialize(CardEntity model)
{
_model = model;
BindData();
}
private void BindData()
{
// Binding 1 chiều: Model -> View
// Cập nhật máu
_model.CurrentHealth
.Subscribe(health =>
{
_healthText.text = health.ToString();
// Logic visual: Đổi màu đỏ nếu bị thương
_healthText.color = health < _model.Data.BaseHealth? Color.red : Color.white;
})
.AddTo(this); // Tự động hủy đăng ký khi GameObject bị Destroy
// Cập nhật tấn công
_model.CurrentAttack
.Subscribe(atk => _attackText.text = atk.ToString())
.AddTo(this);
// Xử lý cái chết
_model.CurrentHealth
.Where(h => h <= 0)
.Subscribe(_ => PlayDeathAnimation())
.AddTo(this);
}
private void PlayDeathAnimation()
{
// Trigger DOTween animation here
// Sau đó báo lại cho hệ thống logic nếu cần
}
}
Lợi ích: CardView hoàn toàn không chứa logic game. Nó chỉ phản chiếu trạng thái của CardEntity. Nếu bạn có hiệu ứng "Buff +2 Attack" từ một lá bài khác, bạn chỉ cần cập nhật CardEntity.CurrentAttack.Value += 2. R3 sẽ tự động lo việc cập nhật số hiển thị trên UI của lá bài đó. Không cần gọi hàm UpdateUI() thủ công ở khắp nơi.
6. Giải quyết Vấn đề Cốt lõi: The "Animation Queue" (Hàng đợi Hoạt ảnh)
Một trong những thách thức lớn nhất của game giống Hearthstone là sự tách biệt giữa Logic State (tính toán tức thì) và Visual State (diễn ra từ từ).
Ví dụ: Người chơi dùng phép "Arcane Missiles" bắn 3 viên đạn.
-
Logic: Máu đối thủ giảm ngay lập tức 3 điểm trong 1 frame.
-
Visual: Phải bắn từng viên: Bắn viên 1 -> Chờ bay -> Nổ -> Trừ máu -> Bắn viên 2...
Nếu không xử lý kỹ, người chơi sẽ thấy máu tụt trước khi đạn bay tới. Để giải quyết, ta cần một Visual Command Queue.
6.1 Thiết kế VisualQueue System
// Interface cho một lệnh hình ảnh
public interface IVisualCommand
{
UniTask ExecuteAsync();
}
// Implement lệnh gây sát thương
public class DamageVisualCommand : IVisualCommand
{
private readonly CardView _targetView;
private readonly int _damageAmount;
public DamageVisualCommand(CardView target, int amount)
{
_targetView = target;
_damageAmount = amount;
}
public async UniTask ExecuteAsync()
{
// Chạy animation rung lắc, nổ particle
await _targetView.PlayDamageAnimAsync();
// Sau đó mới update số máu trên UI (dù logic đã trừ rồi)
_targetView.UpdateHealthUI(_damageAmount);
}
}
// Hệ thống hàng đợi
public class VisualQueueSystem
{
private readonly Queue<IVisualCommand> _queue = new();
private bool _isPlaying = false;
public void Enqueue(IVisualCommand command)
{
_queue.Enqueue(command);
if (!_isPlaying) ProcessQueue().Forget(); // Fire and forget
}
private async UniTaskVoid ProcessQueue()
{
_isPlaying = true;
while (_queue.Count > 0)
{
var cmd = _queue.Dequeue();
await cmd.ExecuteAsync(); // Chờ animation xong mới chạy cái tiếp theo
}
_isPlaying = false;
}
}
Tích hợp: Logic game (CardExecutionSystem) sẽ tính toán kết quả, sau đó đẩy các IVisualCommand tương ứng vào VisualQueueSystem. GameLoopPresenter có thể chọn await cho đến khi hàng đợi rỗng (WaitUntil(() =>!visualQueue.IsPlaying)) trước khi cho phép người chơi thao tác tiếp.
7. Hệ thống Card Effect & ScriptableObject
Để thiết kế thẻ bài dễ dàng (Designer-friendly), ta vẫn cần ScriptableObject, nhưng kết hợp với Strategy Pattern để định nghĩa hiệu ứng.
7.1 Cấu trúc CardData
public class CardData : ScriptableObject
{
public string Id;
public string Name;
public int ManaCost;
public int BaseAttack;
public int BaseHealth;
public Sprite Artwork;
// Danh sách các hiệu ứng (Battlecry, Deathrattle)
// Cho phép đa hình (Polymorphism) trong Inspector (Unity 2019.3+)
public List<CardEffectDefinition> Effects = new();
}
public abstract class CardEffectDefinition
{
public EffectTriggerType Trigger; // OnPlay, OnDeath, OnTurnStart
public abstract ICardEffect CreateEffect(); // Factory method
}
// Ví dụ hiệu ứng gây sát thương
public class DamageEffectDefinition : CardEffectDefinition
{
public int DamageAmount;
public TargetType Target; // Enemy, All, Random
public override ICardEffect CreateEffect()
{
return new DamageEffect(DamageAmount, Target);
}
}
7.2 Xử lý Logic Hiệu ứng
Class DamageEffect (Pure C#) sẽ chứa logic thực thi, được inject các dependency cần thiết thông qua phương thức Resolve.
public class DamageEffect : ICardEffect
{
private readonly int _amount;
private readonly TargetType _targetType;
public DamageEffect(int amount, TargetType targetType)
{
_amount = amount;
_targetType = targetType;
}
public async UniTask ExecuteAsync(GameContext context)
{
var targets = context.TargetSystem.FindTargets(_targetType);
foreach (var target in targets)
{
// Logic: Trừ máu
target.TakeDamage(_amount);
// Visual: Đẩy lệnh vào hàng đợi
context.VisualQueue.Enqueue(new DamageVisualCommand(context.ViewMap[target], _amount));
}
// Chờ một chút nếu cần
await UniTask.Delay(500);
}
}
8. Kết luận và Lời khuyên cho Production
8.1 Tối ưu hóa (Performance)
-
UniTask: Hãy chắc chắn bật
UniTask.Voidcho các event handler (như Button Click) và dùngCancellationTokenđể tránh lỗi khi chuyển scene. -
VContainer: Sử dụng
RegisterEntryPointthay vìStart()của MonoBehaviour để đảm bảo thứ tự khởi tạo chính xác. Tránh dùngInjectlên hàng ngàn GameObject nhỏ; hãy dùng Factory để khởi tạo chúng. -
R3: Cẩn thận với
Where(x => x!= null), hãy dùng toán tửCheckMissingcủa R3 để xử lý việc Unity Object bị destroy (fake null) an toàn.
8.2 Khả năng mở rộng
Kiến trúc này cho phép bạn thêm tính năng mới (ví dụ: Multiplayer) mà không cần đập đi xây lại.
-
Networking: Bạn chỉ cần thay thế
TurnManagerhiện tại bằngNetworkedTurnManager(implement cùng interface). Input của người chơi sẽ không đến từ UI click nữa mà đến từ gói tin mạng. Các phần còn lại (View, CardEntity) không thay đổi. -
AI: Tương tự, tạo
AITurnManagerđể tính toán nước đi và gọi các hàmPlayCardnhư một người chơi bình thường.
Đây là bản thiết kế chi tiết, đáp ứng đầy đủ các yêu cầu của một tựa game thẻ bài hiện đại, hiệu năng cao và dễ bảo trì. Bằng cách tuân thủ nghiêm ngặt sự phân tách giữa Data (ScriptableObject), Logic (Pure C# + UniTask), và View (R3 + MonoBehaviour), dự án của bạn sẽ tránh được "địa ngục Spaghetti code" thường thấy trong phát triển game Unity.
Báo cáo này được tổng hợp dựa trên các nghiên cứu kỹ thuật chuyên sâu về kiến trúc game hiện đại (2024-2025) và tài liệu chính thức của VContainer, UniTask, R3.