ANWENDEN, NICHT NUR LESEN

Abschlussprojekt: eine Charakterklasse durch den gesamten Kurs

Die "Übung" in jedem Artikel testet eine Idee isoliert — genug, um die Idee zu verstehen, aber nicht genug, um zu spüren, wie ein Dutzend solcher Ideen in ein und derselben lebenden Klasse koexistieren, in der Entscheidungen aus früheren Wochen nicht still umgeschrieben werden können, nur weil das heutige Thema nicht zu ihnen passt. Dieses Projekt ist der zweite, parallele Strang: eine Klasse AAcademyCharacter in einem kleinen Survival-Spiel, die der Leser von Level 2 ("Laufzeitobjekte") bis Level 8 ("Produktion") begleitet und dabei bei jedem Schritt genau die Fähigkeit hinzufügt, die das jeweils neue Level liefert — und nichts im Voraus.

Die drei Regeln des Projekts

1. Die Klasse bleibt von Anfang bis Ende dieselbe. Nicht "ein neues Beispiel für jedes Thema", sondern dieselbe AAcademyCharacter (plus Begleitklassen — Components, Controller, GameMode usw.), die mit dem Fortschreiten des Kurses wächst — genau wie ein echtes Projekt in der Entwicklung wächst. Unten stehen 12 Schritte — 00…11 — nicht 12 verschiedene Spiele.

2. Frühere Entscheidungen werden nicht still umgeschrieben. Wenn das Thema des nächsten Schritts einen anderen Ansatz nahelegt als das, was zuvor gemacht wurde, wird dies als explizites Refactoring mit einem "Vorher / Nachher"-Block dokumentiert, der erklärt, was sich geändert hat und warum — nicht als stiller Ersatz. In einem echten Projekt ist es keine Option, eine vergangene Entscheidung ohne Erklärung rückgängig zu machen.

3. Jeder Schritt ist eine Entscheidung, kein Kopieren eines Beispiels. Die Kursartikel liefern UDataAsset, UHealthComponent, UGameplayAbility und Dutzende weiterer Code-Schnipsel als Illustrationen für ihre Themen. Hier besteht die Aufgabe nicht darin, einen Schnipsel zu kopieren, sondern genau zu entscheiden, wie er in eine bereits bestehende Klasse passt, was umbenannt, verallgemeinert oder auf die spezifische Aufgabe dieses Projekts zugeschnitten werden muss.

Der folgende Aufbau folgt strikt den Schritten desselben Spiels — in der Reihenfolge, in der die Kurs-Levels das Material dafür liefern. Jede Karte zeigt, was AAcademyCharacter (oder seine Umgebung) nach diesem Schritt kann und vorher nicht konnte: welche Artikel dafür nötig sind, die konkrete Ergebnisliste, und — wo ein Schritt eine bereits bestehende Datei ändert statt eine neue von Grund auf anzulegen — ein "Vorher / Nachher"-Block mit dem tatsächlichen Code-Diff.

00
Schritt 00 — Der Charakter entsteht (Level 2)
Die erste Codezeile des Projekts — und sie ist bewusst langweilig. AAcademyCharacter erscheint als bloßer Erbe von ACharacter: keine Health, kein Inventar, keine einzige Gameplay-Fähigkeit. Die Kollisionskapsel, das Skeletal Mesh und die UCharacterMovementComponent sind bereits da — sie wurden vom Konstruktor von ACharacter erzeugt und können nur über die bereits existierenden Subobjects geändert werden, nicht neu erstellt (siehe den Artikel zum Actor-Lebenszyklus — warum verpflichtende Subobjects strikt im Konstruktor über CreateDefaultSubobject erzeugt werden, nicht später). Ziel dieses Schritts ist es, sich nicht zu beeilen: bevor Fähigkeiten hinzugefügt werden, klären, was an dieser Klasse eine Gameplay-Entscheidung ist und was einfach kostenlos von der Engine geerbt wird.
AAcademyCharacter.hC++
UCLASS()
class ACADEMYGAME_API AAcademyCharacter : public ACharacter
{
    GENERATED_BODY()
public:
    AAcademyCharacter();
};
AAcademyCharacter.cppC++
AAcademyCharacter::AAcademyCharacter()
{
    // Capsule und Mesh existieren bereits — sie wurden vom Konstruktor von ACharacter erzeugt.
    // Wir fassen nur an, was unseren Charakter tatsächlich vom Standard-Charakter unterscheidet.
    GetCapsuleComponent()->InitCapsuleSize(42.f, 96.f);
    bUseControllerRotationYaw = false;
}
  • Explizit (für sich selbst) begründet, warum AAcademyCharacter von ACharacter erbt und nicht von einem bloßen APawn — Bodenbewegung, Ducken und die Kapsel werden von Tag eins an gebraucht, nicht "könnten mal nützlich sein".
  • Kein Feld wird "für die Zukunft" hinzugefügt — wenn eine Fähigkeit jetzt nicht gebraucht wird, steht sie nicht in der Klasse.
  • Dokumentiert, welche Initialisierung auf BeginPlay warten muss (in diesem Schritt erfordert noch nichts das, aber die Gewohnheit, dies explizit festzuhalten, wird schon ab der ersten Datei etabliert).
01
Schritt 01 — Health und Inventar: Components, keine Felder (Level 2)
Health wird nicht als Charakter-Feld ausgelagert, sondern als eigenständige UHealthComponent — eine Entscheidung, die nicht mit "so macht man das eben" begründet wird, sondern mit genau der kombinatorischen Explosion, die im Artikel zu Components analysiert wird (man stelle sich vor, Health wird sowohl vom Character als auch vom künftigen AAcademyEnemy aus Schritt 08 gebraucht). Aus demselben Grund ist das Inventar UInventoryComponent über TArray<FAcademyInventorySlot>, nicht parallele Arrays aus "Namen + Mengen" direkt in der Charakterklasse. Die Health-Component erhält sofort FOnHealthChanged — ein dynamisches Multicast-Delegate: Es abonniert noch niemand (die UI erscheint erst in Schritt 04), aber ab diesem Moment darf die Component nicht im Voraus wissen, wer ihr zuhören wird.
AAcademyCharacter.h — vorher (Schritt 00)C++
UCLASS()
class ACADEMYGAME_API AAcademyCharacter : public ACharacter
{
    GENERATED_BODY()
public:
    AAcademyCharacter();
};
AAcademyCharacter.h — nachherC++
UCLASS()
class ACADEMYGAME_API AAcademyCharacter : public ACharacter
{
    GENERATED_BODY()
public:
    AAcademyCharacter();

protected:
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
    TObjectPtr<UHealthComponent> HealthComponent;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Components")
    TObjectPtr<UInventoryComponent> InventoryComponent;
};
AAcademyCharacter.cpp — im Konstruktor ergänztC++
HealthComponent = CreateDefaultSubobject<UHealthComponent>(TEXT("HealthComponent"));
InventoryComponent = CreateDefaultSubobject<UInventoryComponent>(TEXT("InventoryComponent"));
HealthComponent.h — neue DateiC++
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnHealthChanged, float, NewHealth);

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class ACADEMYGAME_API UHealthComponent : public UActorComponent
{
    GENERATED_BODY()
public:
    UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Health")
    float MaxHealth = 100.f;

    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Health")
    float CurrentHealth;

    UPROPERTY(BlueprintAssignable)
    FOnHealthChanged OnHealthChanged;

    void ApplyDamage(float Amount);
protected:
    virtual void BeginPlay() override;
};
InventoryComponent.h — neue DateiC++
USTRUCT(BlueprintType)
struct FAcademyInventorySlot
{
    GENERATED_BODY()

    // Der vollständige Typ UAcademyItemDataAsset erscheint erst in Schritt 03 — bis dahin
    // reicht eine Vorwärtsdeklaration; die Component muss nicht wissen, woraus ein Item besteht.
    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    TObjectPtr<UAcademyItemDataAsset> Item;

    UPROPERTY(EditAnywhere, BlueprintReadOnly)
    int32 Count = 0;
};

UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class ACADEMYGAME_API UInventoryComponent : public UActorComponent
{
    GENERATED_BODY()
public:
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Inventory")
    TArray<FAcademyInventorySlot> Slots;

    bool AddItem(UAcademyItemDataAsset* Item, int32 Count);
};
  • UHealthComponent und UInventoryComponent sind wiederverwendbare UActorComponents, keine Felder direkt im Charakter.
  • FOnHealthChanged ist innerhalb der Component deklariert und wird von ihr ausgelöst, wenn sich die Health ändert — nicht von außen jeden Frame abgefragt.
  • TArray<FAcademyInventorySlot> für das Inventar mit einer sinnvollen Slot-Struktur (Item + Menge), keine parallelen Arrays.
Denkanstoß
Der Designer bittet darum, eine zerstörbare Truhe mit eigener Health und derselben Schadensanzeige wie der Charakter hinzuzufügen. Sollte die Truhe von AAcademyCharacter erben, um Health wiederzuverwenden?
Erwartete Argumentation anzeigen

Nein — eine Truhe ist in keinem sinnvollen Sinne ein "Character" (keine Bewegung, kein Input, kein Controller); von AAcademyCharacter für ein einziges gemeinsames Feld zu erben, ist genau der Fehler, der im Artikel zu Components analysiert wird. Die richtige Lösung ist dieselbe UHealthComponent, angehängt an eine separate, viel einfachere Klasse AAcademyChest : public AActor. Die Component ist genau dafür konzipiert, dass diese Wiederverwendung keinen gemeinsamen Vorfahren erfordert — Schritt 08 wird dasselbe Muster auf AAcademyEnemy anwenden.

02
Schritt 02 — Enhanced Input: Steuerung ohne hartcodierte Tasten (Level 3 / 5.A)
Der Charakter kann sich endlich bewegen — aber nicht über "W"/"A"/"S"/"D" im Code, sondern über benannte UInputActions, die über einen UInputMappingContext verbunden sind. Ein zweiter Context — InventoryMappingContext — wird sofort angelegt, damit das künftige Öffnen des Inventars (Schritt 04) nicht mit den regulären Bewegungstasten um dieselben Tasten konkurriert: Er wird beim Öffnen über AddMappingContext hinzugefügt und beim Schließen stets über RemoveMappingContext wieder entfernt. Der Handler für InteractAction ist bereits als Feld deklariert, aber noch nicht verbunden — es gibt noch nichts zum Interagieren; das wird bewusst bis Schritt 03 verschoben, nicht "vorsorglich schon jetzt implementiert".
AAcademyCharacter.h — nachher (ergänzt zu Schritt 01)C++
protected:
    UPROPERTY(EditDefaultsOnly, Category="Input")
    TObjectPtr<UInputMappingContext> DefaultMappingContext;

    UPROPERTY(EditDefaultsOnly, Category="Input")
    TObjectPtr<UInputMappingContext> InventoryMappingContext;

    UPROPERTY(EditDefaultsOnly, Category="Input")
    TObjectPtr<UInputAction> MoveAction;
    UPROPERTY(EditDefaultsOnly, Category="Input")
    TObjectPtr<UInputAction> LookAction;
    UPROPERTY(EditDefaultsOnly, Category="Input")
    TObjectPtr<UInputAction> InteractAction; // verbinden wir in Schritt 03
    UPROPERTY(EditDefaultsOnly, Category="Input")
    TObjectPtr<UInputAction> ToggleInventoryAction;

    virtual void SetupPlayerInputComponent(UInputComponent* PlayerInputComponent) override;
    void Move(const FInputActionValue& Value);
    void Look(const FInputActionValue& Value);
    void ToggleInventory(const FInputActionValue& Value);
AAcademyCharacter.cppC++
void AAcademyCharacter::SetupPlayerInputComponent(UInputComponent* PlayerInputComponent)
{
    if (auto* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(GetController<APlayerController>()->GetLocalPlayer()))
    {
        Subsystem->AddMappingContext(DefaultMappingContext, 0);
    }

    if (auto* EIC = Cast<UEnhancedInputComponent>(PlayerInputComponent))
    {
        EIC->BindAction(MoveAction, ETriggerEvent::Triggered, this, &AAcademyCharacter::Move);
        EIC->BindAction(LookAction, ETriggerEvent::Triggered, this, &AAcademyCharacter::Look);
        EIC->BindAction(ToggleInventoryAction, ETriggerEvent::Started, this, &AAcademyCharacter::ToggleInventory);
    }
}

void AAcademyCharacter::ToggleInventory(const FInputActionValue& Value)
{
    auto* Subsystem = ULocalPlayer::GetSubsystem<UEnhancedInputLocalPlayerSubsystem>(GetController<APlayerController>()->GetLocalPlayer());
    if (!Subsystem) return;

    if (bInventoryOpen) { Subsystem->RemoveMappingContext(InventoryMappingContext); }
    else            { Subsystem->AddMappingContext(InventoryMappingContext, 1); } // höhere Priorität als die Basis
    bInventoryOpen = !bInventoryOpen;
}
  • Mindestens zwei UInputMappingContexts: Basis (Bewegung, Blick, künftiges Interagieren) und Inventar.
  • Das Öffnen des Inventars fügt den zweiten Context mit höherer Priorität hinzu, das Schließen entfernt ihn — zwei aufeinanderfolgende Öffnen-Schließen-Vorgänge hinterlassen keinen doppelten Context.
  • Alle Gameplay-Aktionen sind benannte UInputActions; keine liest eine physische Taste namentlich direkt in C++.
03
Schritt 03 — Interaktion und Items als Daten (Level 4)
Die Welt bekommt aufsammelbare Items. Statt Cast<AAcademyItem> und morgen einem ähnlichen Cast<AAcademyDoor> im Interaktionscode wird UInteractableInterface (UINTERFACE) mit einer Methode Interact eingeführt — eine gemeinsame Fähigkeit ohne gemeinsamen Vorfahren. Die Methode selbst ist BlueprintNativeEvent, kein BlueprintImplementableEvent: C++ behält die Kernlogik (wer interagiert, was mit dem Item passiert), während der Designer visuelle Effekte im Blueprint darüber hinzufügt, ohne das Ganze zu überschreiben. Die Eigenschaften eines Items (Name, Icon, Schaden, Stapel) sind keine C++-Klassenfelder, sondern UAcademyItemDataAsset: Der Designer kann das Spiel balancieren, ohne C++ neu zu kompilieren.
AAcademyCharacter.h — nachher (ergänzt zu Schritt 02)C++
protected:
    // FocusedInteractable — eine starke Referenz: solange das Item im Fokus ist, muss
    // es am Leben und für den GC sichtbar sein wie jeder andere aktive Gameplay-Zustand.
    UPROPERTY()
    TObjectPtr<AActor> FocusedInteractable;

    void Interact(const FInputActionValue& Value);
AAcademyCharacter.cpp — InteractAction endlich verbundenC++
EIC->BindAction(InteractAction, ETriggerEvent::Started, this, &AAcademyCharacter::Interact);

void AAcademyCharacter::Interact(const FInputActionValue& Value)
{
    if (!FocusedInteractable || !FocusedInteractable->Implements<UInteractableInterface>()) return;
    IInteractableInterface::Execute_Interact(FocusedInteractable, this);
}
InteractableInterface.h — neue DateiC++
UINTERFACE(MinimalAPI, Blueprintable)
class UInteractableInterface : public UInterface { GENERATED_BODY() };

class IInteractableInterface
{
    GENERATED_BODY()
public:
    UFUNCTION(BlueprintNativeEvent, Category="Interaction")
    void Interact(AActor* Instigator);
};
AcademyItemDataAsset.h — neue DateiC++
UCLASS(BlueprintType)
class ACADEMYGAME_API UAcademyItemDataAsset : public UDataAsset
{
    GENERATED_BODY()
public:
    UPROPERTY(EditDefaultsOnly, Category="Item")
    FText DisplayName;

    // Eine schwere Vorschau-Textur wird erst benötigt, wenn das Inventar geöffnet ist (Schritt 04) —
    // TSoftObjectPtr zieht sie nicht zusammen mit dem DataAsset selbst in den Speicher.
    UPROPERTY(EditDefaultsOnly, Category="Item")
    TSoftObjectPtr<UTexture2D> Icon;

    UPROPERTY(EditDefaultsOnly, Category="Item")
    int32 MaxStack = 1;

    // Wird vom Angriff aus Schritt 07 genutzt — falls das Item als Waffe ausgerüstet ist.
    UPROPERTY(EditDefaultsOnly, Category="Item")
    float AttackDamage = 0.f;
};
AcademyItem.h — neue Datei, ein aufsammelbares Item im LevelC++
UCLASS()
class ACADEMYGAME_API AAcademyItem : public AActor, public IInteractableInterface
{
    GENERATED_BODY()
public:
    UPROPERTY(EditAnywhere, Category="Item")
    TObjectPtr<UAcademyItemDataAsset> ItemData;

    virtual void Interact_Implementation(AActor* Instigator) override;
};
  • UInteractableInterface, implementiert von AAcademyItem und (künftig) von jeder neuen interaktiven Klasse, ohne gemeinsamen Vorfahren zwischen ihnen.
  • Die Methode Interact ist ein BlueprintNativeEvent, mit erklärter Begründung, warum nicht BlueprintImplementableEvent.
  • Item-Eigenschaften sind UAcademyItemDataAsset, editierbar ohne C++-Neukompilierung; das Icon ist TSoftObjectPtr, keine harte Referenz.
04
Schritt 04 — UI: Health und Inventar auf dem Bildschirm (Level 5.B)
Die Health-Leiste wird als UMG-Widget mit einem BindWidget-Feld für eine UProgressBar gebaut, aber sie abonniert UHealthComponent nicht direkt über Cast<AAcademyCharacter> im Widget — stattdessen erhält das Widget den Wert über UAcademyHealthViewModel (MVVM), das selbst OnHealthChanged der Health-Component aus Schritt 01 abhört. Damit das ViewModel überhaupt an die Components herankommt, muss der Charakter einen kleinen, aber bewussten Schritt zurückgehen: öffentliche Getter zu Feldern hinzufügen, die seit Schritt 01 protected waren — das ist im Projekt das erste Beispiel dafür, dass eine frühere Entscheidung nicht "falsch" war, sondern damals einfach für ein kleineres Problem getroffen wurde.
AAcademyCharacter.h — vorher / nachherC++
// Vorher (Schritt 01): Zugriff nur innerhalb der Klasse und ihrer Unterklassen.
protected:
    TObjectPtr<UHealthComponent> HealthComponent;
    TObjectPtr<UInventoryComponent> InventoryComponent;

// Nachher (Schritt 04): ViewModel und UI-Code leben außerhalb der Charakterklasse und
// haben kein Recht, ihren privaten Zustand zu casten — sie brauchen einen offiziellen Einstiegspunkt.
public:
    UHealthComponent* GetHealthComponent() const { return HealthComponent; }
    UInventoryComponent* GetInventoryComponent() const { return InventoryComponent; }
AcademyHealthViewModel.h — neue DateiC++
UCLASS()
class ACADEMYGAME_API UAcademyHealthViewModel : public UMVVMViewModelBase
{
    GENERATED_BODY()
public:
    void InitFor(UHealthComponent* InHealthComponent);

    UPROPERTY(FieldNotify, BlueprintReadOnly)
    float HealthFraction = 1.f;

private:
    void HandleHealthChanged(float NewHealth);
};
  • Die Health-Leiste ist ein UMG-Widget mit BindWidget, das Daten über ein ViewModel erhält, kein direkter Cast auf AAcademyCharacter.
  • Das ViewModel abonniert FOnHealthChanged der Health-Component aus Schritt 01, statt sie jeden Frame abzufragen.
  • Der Inventar-Bildschirm liest InventoryComponent->Slots nach demselben Getter-Prinzip wie Health — nicht durch direktes Wühlen in den Feldern des Charakters.
Hinweis — Schritt 07 kehrt hierher zurück

In Schritt 07 wandert Health von UHealthComponent zu UAcademyAttributeSet (GAS). Das hier geschriebene UAcademyHealthViewModel muss an die neue Datenquelle neu gebunden werden — das wird nicht versteckt: siehe den "Vorher / Nachher"-Block in Schritt 07.

05
Schritt 05 — Gameplay Framework: nachträgliche Formalisierung der Rollen (Level 3)
Bis zu diesem Schritt vereinte AAcademyCharacter still "Körper" und "Gehirn": Er selbst las Enhanced Input in Schritt 02. Das war damals kein Fehler — Level 3 (Gameplay Framework) war noch nicht behandelt worden —, aber jetzt, wo die Rollen bekannt sind, wird dies explizit als Refactoring dokumentiert, nicht als etwas, das "schon immer so geplant war". Input und Kamera wandern zu AAcademyPlayerController: Er abonniert Enhanced Input und ruft die bereits existierenden Methoden des Charakters auf, statt dessen interne Felder zu lesen. Daten, die Tod und Respawn überleben müssen (die Zahl der eingesammelten Items), wandern zu AAcademyPlayerState. Siegbedingungen und Spawnpunkte — auf dem Server, in AAcademyGameMode.
AAcademyCharacter.h — vorher (Schritte 02–04)C++
protected:
    TObjectPtr<UInputMappingContext> DefaultMappingContext;
    TObjectPtr<UInputMappingContext> InventoryMappingContext;
    TObjectPtr<UInputAction> MoveAction, LookAction, InteractAction, ToggleInventoryAction;

    virtual void SetupPlayerInputComponent(UInputComponent*) override;
    void Move(const FInputActionValue&);
    void Look(const FInputActionValue&);
    void Interact(const FInputActionValue&);
    void ToggleInventory(const FInputActionValue&);
AAcademyCharacter.h — nachher: der gesamte Input-Block entfernt, der Charakter ist nur noch AusführenderC++
public:
    // Wird vom Controller aufgerufen, nicht direkt im Charakter an eine Taste gebunden.
    void RequestMove(const FVector2D& Axis);
    void RequestLook(const FVector2D& Axis);
    void RequestInteract();
    void RequestToggleInventory();
AcademyPlayerController.h — neue Datei, übernimmt den Input-Block vom CharakterC++
UCLASS()
class ACADEMYGAME_API AAcademyPlayerController : public APlayerController
{
    GENERATED_BODY()
protected:
    virtual void SetupInputComponent() override; // dieselbe AddMappingContext/BindAction-Logik wie in Schritt 02

    void Move(const FInputActionValue& Value)
    {
        if (auto* Character = GetPawn<AAcademyCharacter>())
            Character->RequestMove(Value.Get<FVector2D>());
    }
};
AcademyPlayerState.h — neue DateiC++
UCLASS()
class ACADEMYGAME_API AAcademyPlayerState : public APlayerState
{
    GENERATED_BODY()
public:
    // Überlebt Tod und Respawn des Charakters — AAcademyCharacter selbst überlebt das nicht.
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Progress")
    int32 ItemsEverCollected = 0;
};
  • Input- und Kamerasteuerung liegen in AAcademyPlayerController; der Charakter liest Input nicht mehr direkt selbst.
  • Daten, die eine Pawn-Neuerzeugung überleben (die Zahl der eingesammelten Items), liegen in AAcademyPlayerState, nicht in AAcademyCharacter selbst.
  • AAcademyGameMode existiert nur auf dem Server und speichert Sieg-/Spawnregeln; AAcademyGameState repliziert, was alle Clients sehen sollen.
Denkanstoß
Der Charakter stirbt und respawnt nach 5 Sekunden als neuer Pawn. Wo sollte der während des Spiels angesammelte Punktestand gespeichert werden — in AAcademyCharacter, AAcademyPlayerState oder AAcademyGameState?
Erwartete Argumentation anzeigen

In AAcademyPlayerState. AAcademyCharacter wird beim Tod zusammen mit all seinen Feldern zerstört — Daten, die den Tod überleben müssen, dort zu speichern, bedeutet, sie bei jedem Respawn zu verlieren. AAcademyGameState ist ebenfalls die falsche Ebene: Das sind Daten eines bestimmten Spielers, kein gemeinsamer Zustand des Matches. Die Regel ist einfach: Wenn Daten den Tod des Charakters überleben müssen, aber einem bestimmten Spieler gehören — gehören sie in den PlayerState, nicht in Character und nicht in GameState.

06
Schritt 06 — Multiplayer: der Charakter ist nicht mehr lokal (Level 6)
Das erste wirklich schmerzhafte Refactoring des Projekts: Health wird von UHealthComponent zu Replicated, die Interaktion aus Schritt 03 ändert den Zustand nicht mehr direkt auf dem Client und wird zu einer Server-RPC, bei der Entfernung und Cooldown auf dem Server erneut geprüft werden (sonst könnte jeder Client mit einem einzigen Paket ein Item quer über das Level einsammeln). Hier erscheint auch die erste Version des Angriffs — eine direkte Server-RPC, die Health über ApplyDamage abzieht. Das ist bewusst eine naive Implementierung: In Schritt 07 wird daraus eine UGameplayAbility, und hier wird explizit vermerkt, dass sie vorläufig ist.
AAcademyCharacter.h — nachher (ergänzt zu Schritt 05)C++
public:
    void RequestInteract();
    void RequestAttack(); // vorläufig — bis GAS in Schritt 07

protected:
    UFUNCTION(Server, Reliable)
    void ServerInteract(AActor* Target);

    UFUNCTION(Server, Reliable)
    void ServerAttack();
AAcademyCharacter.cppC++
void AAcademyCharacter::ServerInteract_Implementation(AActor* Target)
{
    // Der Server vertraut dem Client nicht — Entfernung und Cooldown werden erneut geprüft,
    // selbst wenn der Client sie für sofortiges Feedback bereits lokal geprüft hat.
    if (!Target || GetDistanceTo(Target) > InteractRange) return;
    IInteractableInterface::Execute_Interact(Target, this);
}

void AAcademyCharacter::ServerAttack_Implementation()
{
    if (AActor* Target = FindAttackTarget())
        Target->FindComponentByClass<UHealthComponent>()->ApplyDamage(10.f);
}
HealthComponent.h — nachher: Replicated + RolleC++
UPROPERTY(ReplicatedUsing=OnRep_CurrentHealth, VisibleAnywhere, BlueprintReadOnly, Category="Health")
float CurrentHealth;

UFUNCTION()
void OnRep_CurrentHealth(); // aktualisiert die Schadensvisualisierung auf Clients und ruft OnHealthChanged auf
  • HealthComponent::CurrentHealth ist UPROPERTY(Replicated), mit einer OnRep-Funktion, die die Schadensvisualisierung auf Clients aktualisiert.
  • Interact aus Schritt 03 ist überarbeitet: Der Client ruft eine Server-RPC auf, der Server prüft Entfernung/Cooldown unabhängig davon, was der Client gesendet hat, erneut.
  • Der Charakter-Code hat mindestens eine Stelle, an der GetLocalRole()/GetRemoteRole() explizit geprüft wird, bevor Logik ausgeführt wird.
Denkanstoß
Nach diesem Schritt wird Interact über eine Server-RPC aufgerufen. Sollte die clientseitige Entfernungsprüfung jetzt entfernt werden, da der Server sie ohnehin erneut prüft?
Erwartete Argumentation anzeigen

Nein — die clientseitige Prüfung bleibt als früher, lokaler Abbruch bestehen und liefert dem Spieler sofortiges Feedback (kein Warten auf die Round-Trip-Zeit zum Server, um zu wissen, ob "zu weit weg"), während die serverseitige Prüfung die einzige Quelle der Wahrheit bleibt, die dem Client nicht anvertraut werden darf. Der Server muss jede Server-RPC erneut prüfen, nicht sich darauf verlassen, dass der Client bereits geprüft hat.

07
Schritt 07 — GAS: Fähigkeiten auf einem bereits replizierten Charakter (Level 5.C)
Das größte Refactoring des Projekts: Health wandert von UHealthComponent (Schritt 01, repliziert in Schritt 06) zu UAcademyAttributeSet als FGameplayAttributeData mit getrenntem Base/CurrentValue — die alte Speicherung als einfaches Replicated float kann einen einzelnen temporären Buff nicht korrekt zurückrollen, wenn mehrere gleichzeitig angewendet werden. Das naive ServerAttack aus Schritt 06 wird zu einer UGameplayAbility mit explizitem ActivateAbility/EndAbility; Schaden wird über ein UGameplayEffect (Instant) angewendet, nicht durch direktes Abziehen. Der Tag State.Attacking über ActivationBlockedTags verhindert, dass die Fähigkeit erneut über sich selbst startet — er ersetzt eine manuelle if-Prüfung. Der Moment des Treffers speichert die Schadensquelle in TWeakObjectPtr<AActor> — der Charakter soll den Angreifer weder besitzen noch ihn allein deshalb vor der Garbage Collection bewahren, weil irgendwo ein Zeiger auf ihn in einem Feld liegt.
AAcademyCharacter.h — vorher (Schritt 06)C++
protected:
    TObjectPtr<UHealthComponent> HealthComponent;
    UFUNCTION(Server, Reliable)
    void ServerAttack();
AAcademyCharacter.h — nachherC++
public:
    virtual UAbilitySystemComponent* GetAbilitySystemComponent() const override { return AbilitySystemComponent; }

protected:
    // HealthComponent aus den Schritten 01/06 wird ersetzt — das alte Health-Feld wird nicht still entfernt:
    // Der Grund ist oben im Text des Schritts dokumentiert; die Component konnte Buffs nicht zurückrollen.
    UPROPERTY()
    TObjectPtr<UAbilitySystemComponent> AbilitySystemComponent;

    UPROPERTY()
    TObjectPtr<UAcademyAttributeSet> AttributeSet;

    // Wer zuletzt Schaden verursacht hat — für das HUD "getötet von Spieler X". Schwache Referenz:
    // Der Charakter besitzt den Angreifer nicht und sollte seine Garbage Collection nicht verhindern.
    TWeakObjectPtr<AActor> LastDamageInstigator;
AcademyAttributeSet.h — neue Datei, ersetzt HealthComponentC++
UCLASS()
class ACADEMYGAME_API UAcademyAttributeSet : public UAttributeSet
{
    GENERATED_BODY()
public:
    UPROPERTY(BlueprintReadOnly, Category="Attributes")
    FGameplayAttributeData Health;
    UPROPERTY(BlueprintReadOnly, Category="Attributes")
    FGameplayAttributeData MaxHealth;

    virtual void PostGameplayEffectExecute(const FGameplayEffectModCallbackData& Data) override;
};
AcademyAttackAbility.h — neue Datei, ersetzt ServerAttackC++
UCLASS()
class ACADEMYGAME_API UAcademyAttackAbility : public UGameplayAbility
{
    GENERATED_BODY()
public:
    UAcademyAttackAbility()
    {
        // Ein Tag statt eines manuellen "if (bIsAttacking) return" — GAS selbst erlaubt
        // der Fähigkeit keinen erneuten Start, bis der Tag in EndAbility entfernt wird.
        ActivationBlockedTags.AddTag(FGameplayTag::RequestGameplayTag(FName("State.Attacking")));
    }

    virtual void ActivateAbility(const FGameplayAbilitySpecHandle Handle, const FGameplayAbilityActorInfo* ActorInfo,
        const FGameplayAbilityActivationInfo ActivationInfo, const FGameplayEventData* TriggerEventData) override;
};
  • UAcademyAttributeSet mit Health/MaxHealth als FGameplayAttributeData — das alte Feld HealthComponent wird explizit als ersetzt markiert, nicht einfach spurlos gelöscht.
  • Der Angriff ist eine UGameplayAbility, aktiviert über den ASC, nicht mehr die alte Server-RPC direkt.
  • Die Schadensanwendung ist ein UGameplayEffect (Instant) mit einem Modifier auf Health, angewendet über ApplyGameplayEffectToTarget.
  • Mindestens eine Fähigkeit verwendet ActivationBlockedTags statt einer manuellen if-Prüfung.
AcademyHealthViewModel.h/.cpp — vorher (Schritt 04) / nachher (Schritt 07)C++
// Vorher (Schritt 04): Das ViewModel kannte nur die Component.
void UAcademyHealthViewModel::InitFor(UHealthComponent* InHealthComponent)
{
    InHealthComponent->OnHealthChanged.AddDynamic(this, &UAcademyHealthViewModel::HandleHealthChanged);
}

// Nachher (Schritt 07): Das ViewModel hört direkt auf das Attribut des ASC, HealthComponent verschwindet aus der Signatur.
void UAcademyHealthViewModel::InitFor(UAbilitySystemComponent* InASC)
{
    InASC->GetGameplayAttributeValueChangeDelegate(UAcademyAttributeSet::GetHealthAttribute())
        .AddUObject(this, &UAcademyHealthViewModel::HandleAttributeChanged);
}
Rückkehr zu Schritt 04 — das ViewModel wird neu gebunden

UAcademyHealthViewModel::InitFor aus Schritt 04 nahm UHealthComponent* entgegen. Jetzt nimmt es UAbilitySystemComponent* entgegen und abonniert GetGameplayAttributeValueChangeDelegate(UAcademyAttributeSet::GetHealthAttribute()) statt OnHealthChanged, wie oben gezeigt. Das ist keine Umschreibung der Geschichte: Regel 2 verlangt, dass ein solcher Schritt als expliziter Refactoring-Punkt dieses Schritts dokumentiert wird, nicht als stille Bearbeitung rückwirkend in Schritt 04.

08
Schritt 08 — KI-Gegner auf denselben Systemen wie der Spieler (Level 5.D)
Der erste NPC-Gegner, AAcademyEnemy, verwendet UAcademyAttributeSet und UGameplayEffect wieder, die in Schritt 07 für den Spieler entworfen wurden — der Moment, in dem sichtbar wird, ob sich die Entscheidungen zur Wiederverwendung ausgezahlt haben. Das Verhalten des Gegners wird als Behavior Tree aufgebaut (patrouillieren → Spieler bemerken → verfolgen → angreifen → bei niedriger HP zurückziehen); der Zustand "aktuelles Ziel" liegt im Blackboard, nicht in einem Controller-Feld, und die Positionswahl für einen Angriff erfolgt über eine EQS-Abfrage, nicht durch manuelle Punktaufzählung in C++.
AcademyEnemy.h — neue DateiC++
UCLASS()
class ACADEMYGAME_API AAcademyEnemy : public ACharacter, public IAbilitySystemInterface
{
    GENERATED_BODY()
public:
    virtual UAbilitySystemComponent* GetAbilitySystemComponent() const override { return AbilitySystemComponent; }

protected:
    // Dieselben Klassen wie in AAcademyCharacter aus Schritt 07 — weder Health
    // noch Schaden werden in einer separaten Implementierung für den Gegner dupliziert.
    UPROPERTY()
    TObjectPtr<UAbilitySystemComponent> AbilitySystemComponent;
    UPROPERTY()
    TObjectPtr<UAcademyAttributeSet> AttributeSet;
};
Behavior Tree — die SkriptstrukturDiagramm
Root
 └─ Selector
     ├─ Sequence "Attack" (Bedingung: Ziel in Angriffsreichweite)
     │   └─ Task: AcademyAttack (aktiviert UAcademyAttackAbility über den ASC)
     ├─ Sequence "Chase" (Bedingung: Ziel sichtbar, HP > 20%)
     │   ├─ Task: EQS-Abfrage "BestChasePoint"
     │   └─ Task: MoveTo (Blackboard-Key TargetActor)
     ├─ Sequence "Retreat" (Bedingung: HP <= 20%)
     │   └─ Task: MoveTo (Rückzugspunkt)
     └─ Task: Patrol (Blackboard-Key PatrolPoint)
BTTask_AcademyAttack.cpp — neue Datei, der Knoten "Attack" aus dem obigen BaumC++
void UBTTask_AcademyAttack::TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
    // Dieselbe Ability-Klasse wie beim Spieler in Schritt 07 — der Gegner bekommt
    // keine eigene Kopie der Angriffslogik, er aktiviert UAcademyAttackAbility über seinen eigenen ASC.
    if (auto* Enemy = Cast<AAcademyEnemy>(OwnerComp.GetAIOwner()->GetPawn()))
    {
        Enemy->GetAbilitySystemComponent()->TryActivateAbilityByClass(UAcademyAttackAbility::StaticClass());
    }
}
  • AAcademyEnemy verwendet dasselbe UAcademyAttributeSet wie der Spieler, ohne dessen Logik zu duplizieren.
  • Behavior Tree mit mindestens fünf Knoten aus dem Szenario "patrouillieren → bemerken → verfolgen → angreifen → zurückziehen".
  • Ein Blackboard-Key "aktuelles Ziel" statt eines AIController-Feldes; mindestens eine EQS-Abfrage zur Wahl eines Angriffspunktes.
  • Der BT-Knoten "Attack" aktiviert dieselbe UAcademyAttackAbility, die der Spieler in Schritt 07 nutzt, über TryActivateAbilityByClass — keine separate Kopie der Angriffslogik für den Gegner.
09
Schritt 09 — Save/Load: den Fortschritt desselben Charakters speichern (Level 8)
Gespeichert wird kein abstraktes Beispiel, sondern der reale Zustand eines bereits bestehenden Projekts: Health aus UAcademyAttributeSet (Schritt 07), der Inhalt von InventoryComponent (Schritt 01) und der Punktestand aus AAcademyPlayerState (Schritt 05). UAcademySaveGame speichert die Save-Version von Tag eins an — nicht weil sie jetzt gebraucht wird, sondern weil "einfach ein Feld hinzufügen" in einem künftigen Update die häufigste Art ist, fremde Spielstände zu zerstören, wenn die Version nicht im Voraus vorgesehen wurde.
AcademySaveGame.h — neue DateiC++
UCLASS()
class ACADEMYGAME_API UAcademySaveGame : public USaveGame
{
    GENERATED_BODY()
public:
    UPROPERTY()
    int32 SaveVersion = 1;

    UPROPERTY()
    float SavedHealth = 100.f;

    UPROPERTY()
    TArray<FAcademyInventorySlot> SavedInventory;

    UPROPERTY()
    int32 SavedItemsEverCollected = 0;
};
AAcademyCharacter.h / .cpp — nachher: das Speichern liest aus bereits existierenden SubsystemenC++
public:
    void SaveProgress();
    void LoadProgress();

// .cpp
void AAcademyCharacter::SaveProgress()
{
    auto* Save = Cast<UAcademySaveGame>(UGameplayStatics::CreateSaveGameObject(UAcademySaveGame::StaticClass()));
    Save->SavedHealth = AbilitySystemComponent->GetNumericAttribute(UAcademyAttributeSet::GetHealthAttribute());
    Save->SavedInventory = InventoryComponent->Slots;
    Save->SavedItemsEverCollected = GetPlayerState<AAcademyPlayerState>()->ItemsEverCollected;
    UGameplayStatics::SaveGameToSlot(Save, TEXT("Slot0"), 0);
}
AAcademyCharacter.cpp — LoadProgress: Health wird über GAS wiederhergestellt, nicht durch direktes SchreibenC++
void AAcademyCharacter::LoadProgress()
{
    auto* Save = Cast<UAcademySaveGame>(UGameplayStatics::LoadGameFromSlot(TEXT("Slot0"), 0));
    if (!Save) return;

    // Health — nicht Save->SavedHealth direkt in das private Feld des AttributeSets geschrieben,
    // sondern über einen GameplayEffect mit SetByCaller: derselbe Weg wie jede andere Health-Änderung in GAS, kein Umweg.
    FGameplayEffectContextHandle Context = AbilitySystemComponent->MakeEffectContext();
    FGameplayEffectSpecHandle Spec = AbilitySystemComponent->MakeOutgoingSpec(UAcademySetHealthEffect::StaticClass(), 1.f, Context);
    Spec.Data->SetSetByCallerMagnitude(FGameplayTag::RequestGameplayTag(FName("Data.Health")), Save->SavedHealth);
    AbilitySystemComponent->ApplyGameplayEffectSpecToSelf(*Spec.Data.Get());

    InventoryComponent->Slots = Save->SavedInventory;
    GetPlayerState<AAcademyPlayerState>()->ItemsEverCollected = Save->SavedItemsEverCollected;
}
  • UAcademySaveGame mit SaveVersion von Tag eins an, nicht nachträglich hinzugefügt nach dem ersten Update, das etwas zerstört hat.
  • Speichert die realen Subsysteme des Projekts — AttributeSet, InventoryComponent, PlayerState — kein abstraktes Beispiel von "eine Zahl speichern".
  • Das Laden stellt Health über GAS wieder her (Attribut-Modifier), nicht durch direktes Schreiben in das private Feld des AttributeSets.
Denkanstoß
Im nächsten Update muss UAcademySaveGame um ein Feld EquippedWeaponId ergänzt werden. Was passiert mit alten Spielständen, wenn das Feld einfach hinzugefügt wird, ohne SaveVersion und die Lade-Logik zu ändern?
Erwartete Argumentation anzeigen

Formal stürzt die Deserialisierung nicht ab — das neue Feld nimmt in alten Spielständen einfach seinen Standardwert an. Das Problem ist nicht ein Absturz, sondern ein stiller Bedeutungsverlust: Das Spiel entscheidet, dass keine Waffe ausgerüstet ist, obwohl der Spieler vor dem Update eine gewählt hatte. Genau deshalb wird SaveVersion von Tag eins an gebraucht — damit kann der Lade-Code explizit fragen "ist dieser Spielstand älter als der Moment, in dem Waffen erschienen sind?" und einen bewussten Standardwert einsetzen, statt sich auf das zufällige Verhalten der Serialisierung zu verlassen.

10
Schritt 10 — Optimierung und Profiling eines bereits bestehenden Projekts (Level 8)
Dieser Schritt handelt von bereits geschriebenem Code, nicht von einem abstrakten Beispiel "man stelle sich ein Projekt mit Performance-Problemen vor". Das Feld Stamina und der Sprint darauf entstanden bereits in Schritt 02 zusammen mit Enhanced Input, aber damals war das nur eine einzige Zeile innerhalb von Move() — niemand hat einen eigenen Tick dafür als eigenes Thema diskutiert, er ist einfach still nebenbei in der Klasse entstanden. Genau deshalb hat die Regeneration der Ausdauer jeden Frame unbemerkt bis zu diesem Schritt überlebt, obwohl es dem Spieler egal ist, ob das in diesem Frame oder fünf Frames später passiert. stat unit/stat game und ein Trace in Unreal Insights zeigen das konkret, nicht hypothetisch: der Tick des Charakters, die BT-Ticks aus Schritt 08 und die Replikation aus Schritt 06 sind drei reale Kandidaten für das Frame-Budget genau dieses Projekts, keine erfundenen.
AAcademyCharacter.cpp — vorherC++
void AAcademyCharacter::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
    // Die Ausdauer regeneriert sich jeden Frame — bei 120 FPS sind das 120 unnötige
    // Aufrufe pro Sekunde für einen Wert, den die UI ohnehin höchstens 10 Mal pro Sekunde aktualisiert.
    Stamina = FMath::Min(MaxStamina, Stamina + StaminaRegenRate * DeltaTime);
}
AAcademyCharacter.cpp — nachherC++
// Tick() wird für diese Logik nicht mehr überschrieben — die Regeneration ist zu einem Timer
// mit einer Frequenz gewandert, die tatsächlich vom Gameplay gebraucht wird, nicht von der Framerate.
void AAcademyCharacter::BeginPlay()
{
    Super::BeginPlay();
    GetWorldTimerManager().SetTimer(StaminaTimer, this, &AAcademyCharacter::RegenStamina, 0.1f, true);
}
  • Mindestens ein realer Tick des Projekts (kein hypothetisches Beispiel) wird durch einen Timer mit begründeter Frequenz ersetzt.
  • Ein Sitzungsprofil wurde mit genau diesem Charakter, dem Gegner aus Schritt 08 und der Replikation aus Schritt 06 in Unreal Insights aufgenommen — keine abstrakte Szene.
  • Konkrete stat-Befehle, die zur Regressionsprüfung nach dieser Optimierung verwendet werden, sind (für sich selbst, schriftlich) benannt.
11
Schritt 11 — Abschließender Build
Bis zu diesem Punkt hat sich AAcademyCharacter von einem bloßen Erben von ACharacter (Schritt 00) zu einem vernetzten Charakter mit komponentenbasierter Health und Inventar (Schritt 01) entwickelt, gesteuert ohne eine einzige hartcodierte Taste (Schritt 02), interagierend mit datengetriebenen Items (Schritt 03), angezeigt auf dem Bildschirm über MVVM (Schritt 04), mit Rollen, die über das gesamte Gameplay Framework verteilt sind (Schritt 05), repliziert (Schritt 06), mit Kampffähigkeiten auf GAS (Schritt 07), mit einem Gegner, der ehrlich dasselbe Kampfsystem wiederverwendet (Schritt 08), der Fortschritt speichert (Schritt 09), und profiliert als reales Projekt statt als hypothetisches Beispiel (Schritt 10). Keiner dieser zehn Schritte hat den vorherigen still umgeschrieben — jedes obige Refactoring zeigt klar "Vorher / Nachher" und den Grund.
💡 Senior Thoughts

Eine isolierte Übung prüft, ob man eine Idee verstanden hat. Ein Abschlussprojekt prüft etwas anderes — ob die heutige Entscheidung dem Druck aller Entscheidungen standhält, die man später treffen wird. Dieser Druck, nicht irgendein einzelner Artikel, ist die eigentliche Quelle architektonischer Erfahrung — in einem echten Projekt kommen Kursthemen nie einzeln nacheinander an.

Was bewusst nicht im Projekt steckt

Level Streaming/World Partition (Level 5.E) und Animation (Level 5.F) sind vollwertige Kursabschnitte mit eigenen Artikeln, wurden aber bewusst aus allen 12 obigen Schritten herausgehalten: Der Testraum, in dem AAcademyCharacter lebt, wächst nie so groß, dass Streaming-Sublevel zu einer echten Notwendigkeit würden statt zu einem künstlich angehängten Beispiel — dasselbe gilt für Charakteranimation über einem bereits vollständigen Satz an Fähigkeiten. Beide Themen bleiben bewusst parallele Stränge von Level 5, nicht in die einzelne Erzähllinie des Survival-Charakters eingewoben, um zu vermeiden, Code zu zeigen "weil das Thema existiert", statt weil genau dieses Projekt ihn braucht. Die Architektur einer Codebasis in einer Größenordnung, die dieses Projekt irgendwann naturgemäß übersteigt, ist das Thema des Abschlussartikels des Kurses.