Qua dự án HOE mình đã đúc rút được 1 số kinh nghiệm và bài này sẽ giải thích mình đã xây dựng base của HOE dựa trên Entitas như thế nào. Cho ai chưa biết thì Entitas là framework ECS cho unity. Bài này sẽ không nói về Entitas, nên nếu anh em nào muốn thì có thể tự tìm hiểu hoặc đợi mình sẽ viết 1 bài khác.
Mục tiêu của phần base này sẽ hướng đến các yếu tố sau, các yếu tố này không phụ thuộc vào framework:
- Có đủ Data, Logic và View
- Đảm bảo sự độc lập giữa những thành phần trên
- Tuân thủ nguyên lý SOLID
Các khái niệm chính:
- Data: là các dữ liệu đơn lẻ, ví dụ vị trí nhân vật, máu của nhân vât, lượng mana của người chơi, state hiện tại của nhân vật …. Trong Entitas thì data nằm trong các Component.
- Logic: phần đảm nhiệm việc biến đổi trạng thái của Data. Ví dụ như logic di chuyển, logic điều khiển projectile, logic xử lý input…, Trong Entitas là các system.
- View: phần đảm nhiệm hiển thị, âm thanh của game.
- Services: phần code thư viện hoặc các service được add vào. Ví dụ: Pathfinding, Log…
- Input: Đảm nhiệm việc biến đổi input từ các nguồn bên ngoài (controller / keyboard / mouse input, network input) thành data mà game có thể dùng được
Abstract in action
Dựa trên SOLID, các thành phần data, loigic, và view sẽ cần phải tách biệt với nhau và việc giao tiếp bằng interface. Phần Data và Logic là core, các thành phần khác điều giao tiếp với 2 phần này qua interface. Các service sẽ đưa dữ liệu và lưu tại Data, phần logic sẽ biến đổi data sau đó sẽ đưa data ra view cũng bằng interface. Data sau khi được xử lý sẽ đưa ra view thông qua View Layer hoặc các event.
Để dễ hiểu thì ta sẽ xây dựng 1 service Log cho game. Trong unity thì sử dụng Debug.Log là điều mà chúng ta sẽ nghĩ đến đầu tiên còn phải xây với dựng làm gì nữa? Tuy nhiên với cách này đáp ứng tốt nếu game chỉ dùng unity và không có nhu cầu thay đổi Log, với yêu cầu tận dụng logic để dùng chung giữa client và server thì việc tạo 1 hệ thống log là điều cần thiết. Ok chúng ta sẽ thử xây dựng Log system bằng ReactiveSystem của Entitas:
using UnityEngine;
using Entitias;
using System.Collections.Generic;
// debug message component
[Debug]
public sealed class DebugLogComponent : IComponent {
public string message;
}
// reactive system to handle messages
public class HandleDebugLogMessageSystem : ReactiveSystem<DebugEntity> {
// collector: Debug.Matcher.DebugLog
// filter: entity.hasDebugLog
public void Execute (List<DebugEntity> entities) {
foreach (var e in entities) {
Debug.Log(e.debugLog.message);
e.isDestroyed = true;
}
}
}
Để gọi log ta cần tạo 1 entity với DebugLogComponent là xong. Cách tiếp cận này khá phù hợp với dự án thuần Unity, tuy nhiên với HOE thì sẽ có 1 phần code dùng chung giữa server và client nên cách này không thể dùng được. Để cải tiến thì đầu tiên chúng ta sẽ tạo 1 interface thay vì gọi trực tiếp Debug.Log()
public interface ILogService {
void LogMessage(string message);
}
public class UnityDebugLogService : ILogService {
public void LogMessage(string message) {
Debug.Log(message);
}
}
// server-side log
using SomeJsonLib;
public class JsonLogService : ILogService {
string filepath;
string filename;
bool prettyPrint;
// etc...
public void LogMessage(string message) {
// open file
// parse contents
// write new contents
// close file
}
}
Tiếp theo là sửa lại LogSystem, ta truyền UnityDebugLogService hoặc JsonLogService vào constructer của system
public class HandleDebugLogMessageSystem : ReactiveSystem<DebugEntity>
{
ILogService _logService;
public HandleDebugLogMessageSystem(Contexts contexts, ILogService logService) {
_logService = logService;
}
// collector: Debug.Matcher.DebugLog
// filter: entity.hasDebugLog
public void Execute (List<DebugEntity> entities) {
foreach (var e in entities) {
_logService.LogMessage(e.DebugLog.message); // using the interface to call the method
e.isDestroyed = true;
}
}
}
sau khi sửa lại ta có thể dễ đang thay đổi cách thức sử dụng log mà không cần sửa lại những phần code cũ.
Inversion of control
Nói qua 1 chút thì Inversion of control là 1 nguyên lý để giảm sự phụ thuộc (decoupling) lẫn nhau của code, lợi ích của việc này là tăng tính linh hoạt, dễ dàng bảo trì, mở rộng. Anh em nào chưa rõ hoặc muốn tìm hiểu sâu về IoC có thể tìm hiểu thêm bài trên blog của công ty hoặc trên mạng cũng có rất nhiều. Còn về ý tưởng khi thiết kế hệ thống service là gom tất cả service lại 1 chỗ, với mỗi service sẽ tạo 1 global instance có thể truy cập dễ dàng, mỗi khi thêm 1 service vào thì không làm ảnh hưởng đến hệ thống cũ đã có.
public class ServiceManager
{
public readonly IApplicationService Application;
public readonly IInputService Input;
public readonly IPoolService Pool;
public readonly IConfigService Config;
public readonly ICameraService Camera;
public readonly ITimeService Time;
public readonly IDebugService Debug;
public ServiceManager(IApplicationService application, IInputService input, IPoolService pool, IConfigService config, ICameraService camera, ITimeService time, IDebugService debug)
{
Application = application;
Input = input;
Pool = pool;
Config = config;
View = view;
Camera = camera;
Time = time;
Debug = debug;
}
}
Với mỗi service ta tạo 1 component, ở đây các service mình gom vào MetaContext, tiếp tục với DebugService ở phần trước:
[Meta, Unique]
public class DebugServiceComponet : IComponent
{
public IDebugService instance;
}
Tiếp theo tạo 1 InitializeSystem để đăng ký service với Context
public class InitDebugService : IInitializeSystem
{
private MetaContext _metaContext;
private IDebugService _debugService;
public InitDebugService(MetaContext metaContext, IDebugService debugService)
{
_metaContext = metaContext;
_debugService = debugService;
}
public void Initialize()
{
_metaContext.ReplaceDebugServiceComponet(_debugService);
}
}
Cuối cùng là tạo 1 Feature để gom các InitSystem lại với nhau
public class ServiceRegistrationSystems : Feature
{
public ServiceRegistrationSystems(Contexts contexts, ServiceManager services) : base("Services")
{
.......
Add(new InitDebugService(contexts.meta, services.Debug));
.......
}
public sealed override Entitas.Systems Add(ISystem system)
{
return base.Add(system);
}
}
Trong GameController hoặc 1 scrip nào đó ta gọi hàm khởi tạo với inplement cụ thể của từng service.
private void Awake()
{
_serviceManager = new ServiceManager(
new UnityApplicationService(),
new UnityInputService(),
new PoolService(),
_configService,
new UnityCameraService(),
new UnityTimeService(),
new UnityDebugService());
var regService = new ServiceRegistrationSystems(_contexts, _serviceManager);
regService.Initialize();
.....
}
Done. Các service khác làm tương tự như thế này là được.
ViewLayer
Đã xây dựng xong phần bên tay trái của sơ đồ hệ thống, bây giờ đến phần bên phải. Phần này chịu trách nhiệm biểu diễn state của trận đấu như animation nhân vật, state của nhân vật, sound effect, thanh máu …. gọi chung là View-Layer. Xây dựng View-Layer cũng tương tự như phần trên. Ví dụ ta sẽ làm phần View controller của nhân vật. Các chức năng cơ bản của phần view được định nghĩa trong interface:
public interface IViewController {
Vector2D Position {get; set;}
Vector2D Scale {get; set;}
bool Active {get; set;}
void InitializeView(Contexts contexts, IEntity Entity);
void DestroyView();
}
Và implement trong Unity sẽ triển khai như sau, script này sẽ được gắn phần này lên các object trong game.
public class UnityGameView : MonoBehaviour, IViewController {
protected Contexts _contexts;
protected GameEntity _entity;
protected IPoolService poolService;
public Vector2D Position {
get {return transform.position.ToVector2D();}
set {transform.position = value.ToVector2();}
}
public Vector3 Scale
{
get { return transform.localScale; }
set { transform.localScale = value; }
}
public bool Active {get {return gameObject.activeSelf;} set {gameObject.SetActive(value);} }
public void InitializeView(Contexts contexts, IEntity Entity) {
_contexts = contexts;
_entity = (GameEntity)entity;
poolService = contexts.meta.objectPoolService.instance;
}
public void DestroyView() {
poolService.Recycle(gameObject);
}
}
Taoj View service tương tự như phần trước bao gồm việc tạo interface, tạo initsystem …
[Game]
public sealed class ViewComponent : IComponent {
public IViewController instance;
}
public interface IViewService {
IViewController LoadAsset(Contexts contexts, IEntity entity, string assetName);
}
[Meta, Unique]
public class ViewServiceComponent : IComponent
{
public IViewService instance;
}
public class UnityViewService : IViewService {
......
public IViewController LoadAsset(Contexts contexts, IEntity entity, string assetName) {
var poolService = contexts.meta.objectPoolService.instance;
var viewGo = poolService.Spawn<GameObject>("Prefabs/" + assetName);
if (viewGo == null) return null;
var viewController = viewGo.GetComponent<IViewController>();
if (viewController != null) viewController.InitializeView(contexts, entity);
return viewController;
}
}
Ta tạo 1 system gọi đến view service để khởi tạo view cho entity
public sealed class CreateViewSystem : ReactiveSystem<GameEntity>
{
private readonly Contexts _contexts;
private readonly IViewService _viewService;
public CreateViewSystem(Contexts contexts) : base(contexts.game)
{
_contexts = contexts;
_viewService = contexts.meta.viewService.instance;
}
protected override ICollector<GameEntity> GetTrigger(IContext<GameEntity> context)
{
return context.CreateCollector(GameMatcher.Asset.Added());
}
protected override bool Filter(GameEntity entity)
{
return entity.hasAsset && !entity.isAssetLoaded;
}
protected override void Execute(List<GameEntity> entities)
{
foreach (var entity in entities)
{
_viewService.LoadAsset(_contexts, entity, true);
}
}
}
Cuối cùng khi muốn update data ra ViewController thì có thể làm như sau
public class UpdatePositionSystem : IExecuteSystem
{
private IGroup<GameEntity> _entityGroup;
public UpdatePositionSystem(Contexts context)
{
_buffer = new List<GameEntity>();
_entityGroup = context.game.GetGroup(GameMatcher.AllOf(GameMatcher.View));
}
public void Execute(){
foreach (var e in entities) {
e.view.instance.Position = e.position.value;
}
}
}
Như vậy chúng ta đã xây dựng khá hoàn chỉnh được base game dựa trên Entitas, trong phần core hoàn toàn không phụ thuộc bất cứ cái gì của Unity, tất cả đều được giao tiếp qua interface, code gọn gàng tuân thủ theo SOLID. Tất cả phần code bên trên đều dựa trên code dự án HOE nhưng đã được tối giản chỉ tập trung vào phần quan trọng. Nếu anh em đọc thấy có gì sai thì comment để mình sửa nhé. Cảm ơn mọi người đã đọc.
Tài liệu tham khảo: https://github.com/sschmid/Entitas-CSharp/wiki/How-I-build-games-with-Entitas-%28FNGGames%29