Files
night-shift/docs/02_architecture.md
T

8.3 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);
    }
}

Nazewnictwo feature to zazwyczaj encja w liczbie mnogiej (Players, Enemies itd.). Gdy liczba mnoga nie brzmi naturalnie, nadajemy klasie nazwę opisującą jej rolę zamiast powtarzać nazwę feature'a - np. feature Terminal trzyma klasę Report, (zamiast Terminal.Terminal).


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

Wyjątek: Godot -> Godot

Domain i Application trzymają się reguły bez wyjątków - tam kontrakt broni czegoś realnego (można podmienić implementację, przetestować logikę bez silnika, nie ma przypadkowego sprzężenia).

Godot tego wsparcia nie potrzebuje - kontrolery są celowo "głupie" i nie są objęte testami automatycznymi, więc kontrakt istniałby tam wyłącznie po to, żeby ominąć ograniczenie edytora Godota ([Export] nie umie wskazywać na typ-interfejs). Dlatego Godot jednego feature'a może importować Godot innego feature'a bezpośrednio, najczęściej po to, żeby zawrzeć w scenie węzeł z innego feature'a ([Export] na konkretną klasę) i wywołać na nim kilka metod.

Players.Godot        -> Interactions.Godot      DOBRZE (kontener sceny, wywołanie metod kontrolera)
Players.Application  -> Interactions.Godot      ŹLE    (logika feature'a nadal tylko przez Contracts)
Players.Domain       -> Interactions.Godot      ŹLE

GetNode/GetNodeOrNull po ścieżce w scenie między feature'ami dalej jest niepożądane - zależność ma być jawna (typowane pole [Export] albo parametr metody Initialize/Use...), nie odnajdywana w drzewie sceny w runtime.

Ten wyjątek nie zwalnia z Prawa Demeter. Rozmawiamy tylko z bezpośrednim sąsiadem - jeśli feature trzyma w sobie kontroler innego feature'a, woła na nim metody sam, zamiast wystawiać go dalej do przechodzenia przez łańcuch (node.Inner.Other.Method()). Kod spoza feature'a nie powinien wiedzieć, że dany kontroler w ogóle zawiera w sobie coś z innego feature'a.

player.SetMovementMode(mode)              DOBRZE (PlayerController sam woła w środku Interaction/Body)
player.Interaction.SetActive(isActive)    ŹLE    (wywołujący łańcuje się przez Playera do Interaction)

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.