6.2 KiB
Architektura projektu
1. Cel i zasada zależności
Architektura ma być prosta do rozwijania, modularna, testowalna i zorganizowana wokół feature'ów, a nie globalnych warstw technicznych.
Kierunek zależności jest zawsze taki sam:
Godot -> Application -> Domain
Domain -> Godot DOBRZE (domena nigdy nie zna silnika)
Godot -> Domain ŹLE (silnik wywołuje domenę)
2. Struktura katalogów
src/
├── Features/
│ ├── Player/
│ │ ├── Contracts/
│ │ ├── Domain/
│ │ ├── Application/
│ │ └── Godot/
│ └── ...
│
├── Shared/
│ ├── Domain/
│ └── Events/
│ ├── Contracts/
│ └── Infrastructure/
│
├── Bootstrap/
│
└── Scenes/
Nie każdy feature potrzebuje wszystkich czterech folderów. Mały feature może mieć tylko np. Domain/ + Godot/. Foldery dokłada się dopiero, gdy pojawia się realna potrzeba.
3. Warstwy architektury
3.1 Domain
Właściwa logika i reguły gry. Zwykłe klasy C#, bez żadnej wiedzy o silniku.
Wrzucamy tam encje, value obiekty, reguły, inwarianty, obliczenia (Health, PlayerData, Norma, Zgloszenie).
Unikamy Node, sceny, input, UI, zapis do pliku - Domain o tym nie wie.
Wyjątkiem są matematyczne value type'y Godota (Vector2, Vector3, Mathf) - one są dozwolone.
namespace Game.Features.Player.Domain;
public sealed class Health
{
public int Current { get; private set; }
public int Max { get; }
public bool IsDead => Current <= 0;
public Health(int max)
{
Max = max;
Current = max;
}
public void TakeDamage(int amount)
{
Current = Math.Max(0, Current - amount);
}
}
3.2 Application
Koordynacja przypadków użycia (use case'ów) - logika, która dotyka kilku obiektów domenowych, korzysta z portów albo publikuje eventy.
Wrzucamy klasy typu SpawnEnemy, FindNearestEnemy - jedna klasa = jeden przypadek użycia.
namespace Game.Features.Combat.Application;
public sealed class Attack
{
public void Execute(Character attacker, IDamageable target)
{
if (!attacker.CanAttack) return;
target.TakeDamage(attacker.CalculateDamage());
}
}
3.3 Contracts
Publiczne API feature'a. Jedyna część feature'a, którą wolno importować z zewnątrz.
Wrzucamy identyfikatory, DTO/read-modele, eventy, małe interfejsy (porty publiczne).
Unikamy logiki. Kontrakt to tylko kształt danych i sygnatury - nie implementacja.
namespace Game.Features.Enemies.Contracts;
public readonly record struct EnemyId(Guid Value);
public sealed record EnemyInfo(EnemyId Id, Vector2 Position, bool IsAlive);
public interface IEnemyQuery
{
IReadOnlyCollection<EnemyInfo> GetAliveEnemies();
}
3.4 Godot
Adapter między silnikiem a grą. Cienka warstwa łącząca Nodey z logiką w Domain/Application.
Wrzucamy wszystko co związane z silnikiem gry: Node, CharacterBody3D, Area3D, AnimationPlayer, obsługę inputu, sygnały, sceny.
Unikamy logiki gry. Node tylko woła Domain/Application i mapuje wyniki z powrotem na silnik.
namespace Game.Features.Player.Godot;
public partial class PlayerController : Node3D
{
[Export] public CharacterBody3D Body;
private Health _health = null!;
public void Initialize(Health health)
{
_health = health;
}
}
3.5 Shared
Wspólne, stabilne koncepty używane przez wiele feature'ów.
Proste value obiekty lądują płasko w Shared/Domain:
namespace Game.Shared.Domain;
public readonly record struct EntityId(Guid Value);
Jeśli współdzielony koncept ma też port + implementację (np. event bus, z którego korzysta wiele feature'ów), dostaje własny podfolder z tym samym podziałem Contracts/Infrastructure, co niżej:
Shared/
└── Events/
├── Contracts/
│ ├── IEvent.cs
│ └── IEventBus.cs
└── Infrastructure/
└── InMemoryEventBus.cs
3.6 Infrastructure
Implementacja portu z Contracts.
Wrzucamy konkretne, techniczne implementacje interfejsów zdefiniowanych w Contracts.
Unikamy logiki domenowej i unikamy trzymania w tym folderze samego portu (interfejs zawsze zostaje w Contracts).
namespace Game.Shared.Events.Infrastructure;
public sealed class InMemoryEventBus : IEventBus
{
// implementacja portu
}
3.7 Bootstrap
Wiring implementacji portów w całość.
Wrzucamy skrypt(y) spinające całą grę: tworzenie konkretnych implementacji portów, GetNode<T>() po dzieci sceny głównej, wywołania Initialize(...).
Unikamy logiki feature'a (to należy do Godot/ danego feature'a) i lokalnych, jedno-portowych implementacji (to należy do Infrastructure/ danego modułu).
namespace Game.Bootstrap;
public partial class GameBootstrap : Node
{
public override void _Ready()
{
var eventBus = new InMemoryEventBus();
var player = GetNode<PlayerController>("Player");
player.Initialize(eventBus);
}
}
3.8 Scenes
Sceny i geometria świata, które same w sobie nie niosą logiki gry - poziomy, propy, statyczne elementy środowiska.
Wrzucamy pliki .tscn, niepowiązane bezpośrednio z jednym feature'em.
Unikamy logiki - jeśli scena zaczyna mieć skrypt z regułami gry, ten skrypt trafia do odpowiedniego feature'a (Godot/), nie tutaj.
4. Zależności między feature'ami
Zasada: feature A może importować tylko FeatureB.Contracts.
Targeting.Application -> Enemies.Contracts DOBRZE
Loot.Application -> Enemies.Contracts DOBRZE
Targeting -> Enemies.Domain ŹLE
Loot -> Enemies.Application ŹLE
Combat -> Enemies.Godot ŹLE
Eventy między feature'ami
Gdy jedno zdarzenie interesuje wiele feature'ów, feature źródłowy publikuje event i nie wie, kto go słucha:
Enemies -> EnemyKilled -> Loot
-> Quests
-> Score
Enemies nie zna Loot, Quests ani Score.