DungeonCrawler-UE-C/Planung/plan_v1.md
sandr 1e723748b4 Remove GetSocketWorldTransformByIndex – replaced with error logging
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 15:51:22 +02:00

63 KiB
Raw Blame History

Prozeduraler Dungeon Generator Komplette Neuanleitung v4.2

Überblick

Snap-Together Dungeon Generator für Unreal Engine 5.7 mit Blueprints. Vorgefertigte Räume docken an Arrow-Component-Sockets aneinander an. 5100 Level pro Dungeon, Hauptpfad + optionale Abzweigungen (Branches).

Ablauf: Generate Dungeon → Level für Level bauen (Entrance/PortalIn → MainPath → PortalOut/Exit) → nach Exit: Branches auf allen Leveln bauen → offene Sockets versiegeln → Spieler platzieren → Fertig.

Änderungen gegenüber v4 (final)

  1. Runtime-State zentral im Generator unverändert übernommen.
  2. Stabile Socket-IDs unverändert übernommen.
  3. LevelVerticalOffset als Variable unverändert übernommen.
  4. NEU: Detaillierte Update-Logik für AllTrackedSockets Jede Stelle, an der ein Socket-State geändert wird, hat jetzt eine Schritt-für-Schritt Anleitung mit Find-by-Predicate + Set Array Elem.
  5. NEU: BranchFrontier-Kopie BuildBranches iteriert über eine Kopie, nicht das Original, um Array-Mutation während Iteration zu vermeiden.
  6. NEU: Socket-Auswahl beim Startraum Konkrete Logik, welcher Socket auf MainPath geht.
  7. NEU: Retry-Logik komplett definiert RetryLevel als eigenes Custom Event.
  8. NEU: FindCompatibleRoom Socket-Matching Konkrete Kriterien für "nutzbarer Eingangs-Socket".
  9. NEU: RegisterRoomSockets ohne TargetArray-Parameter Arbeitet direkt auf den Generator-Variablen.
  10. NEU: SnapRoomToSocket 180°-Rotation erklärt Warum Combine Rotators mit 180° nötig ist.

Bewusst nicht übernommene Vorschläge

Vorschlag Entscheidung Begründung
BP_DungeonRoomBase als gemeinsame Elternklasse Nicht übernommen UE erlaubt keine Viewport-Änderungen in Child Blueprints. Wir nutzen eigenständige Duplikate + Interface, was flexibler ist für unterschiedliche Raum-Geometrien.
Kollision vor Spawn prüfen (CanPlaceRoom) Nicht übernommen (vorerst) Spawn-then-Destroy funktioniert für den Prototyp. Optimierung auf Box Trace kommt später wenn die Grundlogik steht.
Branches während des MainPath-Baus Nicht übernommen Branches nach dem Exit über alle Level ist einfacher zu implementieren und zu debuggen.
Erweiterte Data Table (Tags, FootprintExtent, CanBeMainPath etc.) Nicht übernommen (vorerst) Die einfache Data Table reicht für den Prototyp.
FindCompatibleRoom mit Score-System Nicht übernommen (vorerst) Gewichtete Zufallswahl reicht erstmal.
E_SocketType (Standard, Large, Stair, Portal) Nicht übernommen (vorerst) Alle Räume haben aktuell die gleiche Durchgangsgröße.
SealOpenSockets als eigener Schritt Übernommen DeadEnds schließen Branches ab. Übrige offene Sockets werden mit WallBlockern verschlossen.

Teil 1: Datenstrukturen

1.1 Enums

E_RoomType (existiert bereits, ggf. erweitern):

  • Entrance, General, Corridor, PortalIn, PortalOut, Exit, DeadEnd

E_SocketStatus (existiert bereits):

  • Open, Occupied, Closed

E_PathType (NEU erstellen):

  • MainPath, Branch, None

1.2 Structs

S_SocketDefinition (NEU ersetzt das alte S_SocketData in den Räumen)

Beschreibt einen Socket am Raum. Wird im Construction Script befüllt. Enthält keinen Laufzeit-Zustand.

Feld Typ Beschreibung
SocketId Name Eindeutiger Name z.B. "Socket_North", "Socket_South"
SocketComponent Arrow Component Ref Referenz auf die Arrow Component
LocalTransform Transform Lokale Position/Rotation relativ zum Actor

S_OpenSocketData (NEU Runtime-State im Generator)

Das ist der wichtigste Struct. Der Generator verwaltet damit alle offenen Verbindungspunkte. Ersetzt das alte S_SocketData für Laufzeit-Zwecke.

Feld Typ Beschreibung
OwnerRoom Actor Object Ref Der Raum dem dieser Socket gehört
SocketId Name Eindeutige Socket-ID (z.B. "Socket_North")
SocketIndex Integer Index in der Arrow-Liste (Fallback)
PathType E_PathType MainPath, Branch oder None
LevelIndex Integer Auf welchem Level liegt dieser Socket
BranchDepth Integer Tiefe der Abzweigung (0 = am MainPath)
State E_SocketStatus Open, Occupied, Closed

WICHTIG: WorldTransform wird NICHT im Struct gespeichert! Die Position wird immer live über GetSocketWorldTransformById berechnet.

S_PlacedRoomData (NEU Raum-Tracking im Generator)

Speichert Informationen über jeden platzierten Raum zentral im Generator.

Feld Typ Beschreibung
RoomActor Actor Object Ref Referenz auf den gespawnten Actor
RoomType E_RoomType Typ des Raums
PathType E_PathType MainPath oder Branch
LevelIndex Integer Auf welchem Level
BranchDepth Integer Abzweigungstiefe (0 = MainPath)

S_RoomPoolEntry (existiert bereits, unverändert)

Feld Typ Beschreibung
RoomClass Actor Class Ref Blueprint-Klasse des Raums
RoomType E_RoomType Typ
Weight Float Gewichtung für Zufallswahl
MinLevel Integer Ab welchem Level verfügbar
MaxLevel Integer Bis welchem Level (0 = unbegrenzt)

DT_RoomPool erstellen (Data Table im Editor)

Die Data Table DT_RoomPool ist die zentrale Tabelle, aus der der Generator zur Laufzeit Räume auswählt. Sie basiert auf dem Struct S_RoomPoolEntry.

Voraussetzung: Der Struct S_RoomPoolEntry muss bereits existieren (siehe oben).

Schritt 1 Data Table Asset anlegen:

  1. Im Content Browser: Rechtsklick → MiscellaneousData Table
  2. Im Popup "Pick Row Structure": Wähle S_RoomPoolEntry als Row Structure
  3. Bestätige mit OK
  4. Benenne das neue Asset: DT_RoomPool
  5. Speichere es z.B. unter Content/Data/DT_RoomPool (oder einem Ordner deiner Wahl)

Schritt 2 Zeilen hinzufügen:

Öffne DT_RoomPool per Doppelklick. Die Tabelle ist zunächst leer.

  1. Klicke oben links auf Add (+ Symbol), um eine neue Zeile hinzuzufügen
  2. Vergib einen eindeutigen Row Name (z.B. "General_01", "Corridor_01", "DeadEnd_01")
  3. Befülle die Felder der Zeile:
Feld Was eintragen Hinweis
RoomClass Blueprint-Klasse auswählen (z.B. BP_DungeonRoom_General) Klick auf das Dropdown → Blueprint-Class wählen
RoomType Passenden Enum-Wert wählen (z.B. General) Muss zum Blueprint passen
Weight Gewichtung als Float (z.B. 1.0) Höher = häufiger gewählt
MinLevel Ab welchem Level verfügbar (z.B. 1) 1 = ab dem ersten Level
MaxLevel Bis welchem Level (z.B. 0) 0 = unbegrenzt / auf allen Levels

Schritt 3 Empfohlene Einträge für den Prototyp:

Row Name RoomClass RoomType Weight MinLevel MaxLevel
General_01 BP_DungeonRoom_General General 1.0 1 0
Corridor_01 BP_DungeonRoom_Corridor Corridor 1.0 1 0
DeadEnd_01 BP_DungeonRoom_DeadEnd DeadEnd 1.0 1 0

Hinweis: Entrance, PortalIn, PortalOut und Exit werden nicht in die Data Table eingetragen diese Räume spawnt der Generator direkt per Klasse (hartcodiert in BuildLevel / PlaceEndRoom). Nur Räume, die per Zufallswahl platziert werden (General, Corridor, DeadEnd), gehören hier rein.

Tipp: Du kannst später weitere Zeilen hinzufügen (z.B. General_02, General_03 mit verschiedenen Gewichtungen oder Level-Bereichen), um mehr Vielfalt zu erzeugen.

Schritt 4 Im Generator referenzieren:

Im BP_DungeonGenerator: Die Variable RoomPoolTable (Typ: Data Table Reference) auf DT_RoomPool setzen. Das geschieht im Details Panel unter dem Default Value:

  1. Wähle die Variable RoomPoolTable aus
  2. Im Details Panel → Default Value → Dropdown → wähle DT_RoomPool

1.3 Was wird gelöscht/ersetzt

  • S_SocketData bleibt in den Räumen, wird aber auf S_SocketDefinition reduziert (nur noch statische Daten)
  • Felder die aus S_SocketData entfernt werden: Status, IsOnMainPath, OwnerRoom, WorldLocation, WorldRotation
  • Der gesamte Laufzeit-Zustand wandert in S_OpenSocketData und S_PlacedRoomData im Generator

Teil 2: Stabile Socket-IDs einführen

2.1 Arrow Components benennen

In jedem Raum-Blueprint: Benenne die Arrow Components eindeutig.

Beispiel für einen 4-Socket-Raum:

  • Arrow Component 1 → Name: Socket_North
  • Arrow Component 2 → Name: Socket_East
  • Arrow Component 3 → Name: Socket_South
  • Arrow Component 4 → Name: Socket_West

Beispiel für DeadEnd (1 Socket):

  • Arrow Component 1 → Name: Socket_Entry

Beispiel für Corridor (2 Sockets):

  • Arrow Component 1 → Name: Socket_Entry
  • Arrow Component 2 → Name: Socket_Exit

Die Namen müssen innerhalb eines Raums eindeutig sein, dürfen aber zwischen verschiedenen Raumtypen gleich heißen.

2.2 Variablen pro Raum-Blueprint

Jeder Raum-Blueprint braucht nur die wirklich nötigen Variablen. Der Laufzeit-State liegt nicht im Raum, sondern im Generator.

Pflicht-Variablen in jedem Raum:

Variable Typ Default Zweck
RoomType E_RoomType je nach Raum Gibt den Typ des Raums zurück
SocketDefinitions Array of S_SocketDefinition leer Wird im Construction Script aus den Arrow Components befüllt

Optional, nur wenn du sie wirklich brauchst:

Variable Typ Default Zweck
RoomMesh / zusätzliche Mesh-Referenzen Static Mesh Component Ref Nur falls du im Graph direkt darauf zugreifen willst
BoundingBox Box Collision Ref Für Overlap/Kollisionsprüfung
DebugName Name oder String leer Optional für Prints und Debugging

Nicht mehr im Raum speichern:

  • Sockets als Array<S_SocketData>
  • ConnectedRooms
  • IsOnMainPath
  • BranchDepth
  • SocketStatus / Occupied / Closed
  • OwnerRoom
  • WorldLocation / WorldRotation als gespeicherte Runtime-Werte

RoomType-Defaults je Raum:

Blueprint RoomType Default
BP_DungeonRoom_Entrance Entrance
BP_DungeonRoom_General General
BP_DungeonRoom_Corridor Corridor
BP_DungeonRoom_PortalIn PortalIn
BP_DungeonRoom_PortalOut PortalOut
BP_DungeonRoom_Exit Exit
BP_DungeonRoom_DeadEnd DeadEnd

2.3 Construction Script anpassen (alle Räume)

Das Construction Script befüllt jetzt S_SocketDefinition statt S_SocketData:

  1. Construction Script ▶Clear SocketDefinitions
  2. Get Components by Class (Arrow Component, Target: self)
  3. ForEachLoop (Array: Return Value)
  4. Loop Body ▶Make S_SocketDefinition:
    • SocketId: Array Element → Get Display Name (oder Get Object Name) → Convert to Name (z.B. "Socket_North")
    • SocketComponent: Array Element (die Arrow Component Referenz selbst)
    • LocalTransform: Array Element → Get Relative Transform
  5. Add zu SocketDefinitions Array (Variable im Raum, Typ: Array of S_SocketDefinition)
  6. ForEachLoop Completed ▶ → (Ende)

Blueprint-Nodes Schritt für Schritt:

Construction Script ▶ → Clear (SocketDefinitions)
                     → Get Components by Class (Arrow Component)
                     → ForEachLoop
    Loop Body ▶ → Make S_SocketDefinition
                   ├─ SocketId: Array Element → Get Object Name → To Name
                   ├─ SocketComponent: Array Element
                   └─ LocalTransform: Array Element → Get Relative Transform
                → Add (SocketDefinitions, S_SocketDefinition)
    Completed ▶ → (Ende)

2.4 GetSocketWorldTransformById implementieren

Die Interface-Funktion sucht einen Socket anhand seiner ID und gibt die aktuelle Weltposition zurück:

GetSocketWorldTransformById (NEU im Interface):

  • Input: SocketId (Name)
  • Output: Location (Vector), Rotation (Rotator), Success (Bool)

Implementierung in jedem Raum:

  1. SocketDefinitions Array → ForEachLoop
  2. Branch: Array Element → Break S_SocketDefinition → SocketId → Equal (Name) → Input SocketId
  3. → True ▶: Array Element → Break S_SocketDefinition → SocketComponent → GetWorldLocation → Set Location
  4. → Array Element → Break S_SocketDefinition → SocketComponent → GetWorldRotation → Set Rotation
  5. Return (Location, Rotation, Success: true)
  6. → ForEachLoop Completed ▶: Return (Location: 0,0,0, Rotation: 0,0,0, Success: false)

WICHTIG: Wir holen Location/Rotation direkt von der Arrow Component Referenz (SocketComponent), nicht aus dem gespeicherten LocalTransform. So bekommen wir immer die aktuelle Weltposition, auch wenn der Actor bewegt wurde.

2.5 BPI_DungeonRoom Interface aktualisieren

Funktion Inputs Outputs
GetSocketDefinitions SocketDefinitions (Array S_SocketDefinition)
GetSocketWorldTransformById SocketId (Name) Location (Vector), Rotation (Rotator), Success (Bool)
GetRoomType RoomType (E_RoomType)

Entfernte Funktionen (Laufzeit-State liegt jetzt im Generator):

  • SetIsOnMainPath → wird über S_PlacedRoomData im Generator gesetzt
  • GetIsOnMainPath → wird über S_PlacedRoomData im Generator gelesen
  • SetBranchDepth → wird über S_PlacedRoomData im Generator gesetzt
  • GetBranchDepth → wird über S_PlacedRoomData im Generator gelesen
  • GetSockets → ersetzt durch GetSocketDefinitions

Teil 3: BP_DungeonGenerator Variablen

3.1 Dungeon-Parameter (Instance Editable)

Variable Typ Default Beschreibung
Difficulty Float 0.5 0.01.0, bestimmt Größe
RandomSeed Integer 0 Seed für Reproduzierbarkeit
RoomPoolTable Data Table Ref DT_RoomPool Referenz auf die Raum-Tabelle
MinRoomsPerLevel Integer 5 Minimum Räume pro Level
MaxRoomsPerLevel Integer 15 Maximum Räume pro Level
MinLevels Integer 5 Minimum Dungeon-Level
MaxLevels Integer 20 Maximum Dungeon-Level
MaxRetries Integer 5 Retry-Limit pro Level
LevelVerticalOffset Float 1000.0 Z-Abstand zwischen Level-Ebenen
MaxBranchDepthMin Integer 1 Minimale Branch-Tiefe (für Random Range)
MaxBranchDepthMax Integer 5 Maximale Branch-Tiefe (für Random Range)

3.2 Laufzeit-Variablen (nicht Instance Editable)

Variable Typ Default Beschreibung
TotalLevels Integer 0 Berechnete Level-Anzahl
CurrentLevel Integer 0 Aktuelles Level
MaxRoomsOfLevel Integer 0 Berechnete Räume pro Level
CurrentRoomsOnLevel Integer 0 Zähler platzierte Räume
PlacedRooms Array (S_PlacedRoomData) leer Alle platzierten Räume mit Metadaten
PlacedRoomActors Array (Actor Obj Ref) leer Alle Raum-Actors (für Destroy bei Retry)
AllTrackedSockets Array (S_OpenSocketData) leer Zentrale Wahrheitsquelle für alle Sockets aller platzierten Räume
MainPathFrontier Array (S_OpenSocketData) leer Offene Sockets am MainPath (Arbeitsliste)
BranchFrontier Array (S_OpenSocketData) leer Alle Branch-Socket-Kandidaten (Arbeitsliste)
StartRoom Actor Object Ref None Aktueller Startraum
EndRoom Actor Object Ref None Aktueller Endraum
LastPortalOut Actor Object Ref None Letzter PortalOut
RetryCount Integer 0 Aktueller Retry-Zähler
SpawnedBlockers Array (Actor Obj Ref) leer Alle WallBlocker
CurrentSocket S_OpenSocketData Temporär: aktuell verarbeiteter Socket
OpenSocketsBuffer Array (S_OpenSocketData) leer Wiederverwendbarer Puffer für Socket-Verteilung immer clearen vor Verwendung
RandomPickedIndex Integer 0 Wiederverwendbar für zufälligen Socket-Pick

Teil 4: BP_DungeonGenerator Event Graph

4.1 Custom Events erstellen

  1. GenerateDungeon Keine Parameter
  2. BuildLevel Keine Parameter
  3. BuildMainPath Keine Parameter
  4. PlaceEndRoom Keine Parameter
  5. BuildBranches Keine Parameter
  6. SealOpenSockets Keine Parameter
  7. PlacePlayer Keine Parameter
  8. RetryLevel Keine Parameter (NEU)

4.2 BeginPlay

  1. Event BeginPlay ▶GenerateDungeon

4.3 GenerateDungeon

  1. GenerateDungeon ▶
  2. Set RandomSeed (Set Random Stream Seed oder Random Integer in Range falls Seed == 0)
  3. Print String: "Seed: " + RandomSeed (für Reproduzierbarkeit)
  4. Lerp (Float): A = MinLevels, B = MaxLevels, Alpha = Difficulty → Round to IntSet TotalLevels
  5. Lerp (Float): A = MinRoomsPerLevel, B = MaxRoomsPerLevel, Alpha = Difficulty → Round to IntSet MaxRoomsOfLevel
  6. Set CurrentLevel = 1
  7. Set CurrentRoomsOnLevel = 0
  8. Set RetryCount = 0
  9. Clear PlacedRooms, PlacedRoomActors, AllTrackedSockets, MainPathFrontier, BranchFrontier, SpawnedBlockers
  10. BuildLevel

Exec-Kette: Alle Setter und Clears müssen in der Exec-Kette verbunden sein!

GenerateDungeon ▶ → Set RandomSeed ▶ → Print String ▶ → Lerp+Set TotalLevels ▶
→ Lerp+Set MaxRoomsOfLevel ▶ → Set CurrentLevel ▶ → Set CurrentRoomsOnLevel ▶
→ Set RetryCount ▶ → Clear (alle Arrays) ▶ → BuildLevel

4.4 BuildLevel

  1. BuildLevel ▶
  2. Set CurrentRoomsOnLevel = 0
  3. Clear MainPathFrontier
  4. Branch: CurrentLevel == 1?

True ▶ (Erstes Level Entrance):

  1. SpawnActor BP_DungeonRoom_Entrance, Location: (0, 0, 0), Rotation: (0, 0, 0)
  2. Set StartRoom = Return Value
  3. Add Return Value zu PlacedRoomActors
  4. RegisterRoomSockets aufrufen (Funktion, Room = Return Value, BranchDepth = 0)
    • Füllt OpenSocketsBuffer mit allen Sockets (PathType: Branch, State: Open)
    • AllTrackedSockets wird noch NICHT befüllt das passiert nach dem Random Pick

Socket-Verteilung für den Startraum (Schritt 9):

  1. → OpenSocketsBuffer verteilen (kein Filter nötig Buffer enthält nur Sockets dieses Raums):

    • Schritt 9a: Zufälligen MainPath-Socket aus OpenSocketsBuffer wählen
    • Schritt 9b: Gewählten Socket mit PathType: MainPath in AllTrackedSockets eintragen → Add zu MainPathFrontier
    • Schritt 9c: Alle übrigen mit PathType: Branch in AllTrackedSockets eintragen → Add zu BranchFrontier

    Warum zufällig? "Ersten nehmen" wäre immer derselbe Socket (abhängig von der Reihenfolge der Arrow Components im Blueprint-Viewport), nicht vom Seed gesteuert. Der Zufalls-Pick sorgt dafür, dass der MainPath-Ausgang bei jedem Seed anders liegen kann.

    Konkret in Blueprints:

    // Schritt 9a  Zufälligen MainPath-Socket wählen
    Branch: OpenSocketsBuffer Length > 0?
      True ▶ → Random Integer in Range from Stream
                 (Stream: RandomStream, Min: 0, Max: OpenSocketsBuffer Length - 1)
              → Set RandomPickedIndex
    
    // Schritt 9b + 9c  Alle Buffer-Einträge in AllTrackedSockets eintragen und verteilen
    OpenSocketsBuffer → ForEachLoop
      Loop Body ▶ → Branch: Array Index == RandomPickedIndex?
        True ▶  → Array Element → Break S_OpenSocketData
                → Make S_OpenSocketData
                    ├─ OwnerRoom, SocketId, SocketIndex, LevelIndex, BranchDepth, State: jeweiliger Pin von Break
                    └─ PathType: MainPath  ← einzige Änderung
                → Add zu AllTrackedSockets
                → Add zu MainPathFrontier
        False ▶ → Array Element → Add zu AllTrackedSockets   (PathType bereits Branch, kein Break/Make nötig)
                → Array Element → Add zu BranchFrontier
    

    Gemeinsame Variablen (Generator-Ebene, überall wiederverwenden):

    • OpenSocketsBuffer Array of S_OpenSocketData (wird von RegisterRoomSockets geleert und befüllt)
    • RandomPickedIndex Integer
  2. Make S_PlacedRoomData (RoomActor: StartRoom, RoomType: Entrance, PathType: MainPath, LevelIndex: CurrentLevel, BranchDepth: 0) → Add zu PlacedRooms

  3. BuildMainPath

False ▶ (Level 2+ PortalIn):

  1. → LastPortalOut → GetActorLocationSubtract Vector(0, 0, LevelVerticalOffset) → Set PortalInLocation
  2. SpawnActor BP_DungeonRoom_PortalIn, Location: PortalInLocation, Rotation: (0, 0, 0)
  3. Set StartRoom = Return Value
  4. Add Return Value zu PlacedRoomActors
  5. RegisterRoomSockets aufrufen (Funktion, Room = Return Value, BranchDepth = 0)
    • Füllt OpenSocketsBuffer mit allen Sockets (PathType: Branch, State: Open)

Socket-Verteilung (identisch zu oben):

  1. → Random Pick aus OpenSocketsBuffer → gewählter Socket mit PathType: MainPath in AllTrackedSockets + MainPathFrontier → Rest mit PathType: Branch in AllTrackedSockets + BranchFrontier (siehe Schritt 9 oben für vollständige Blueprint-Logik)

  2. Make S_PlacedRoomData (RoomActor: StartRoom, RoomType: PortalIn, PathType: MainPath, LevelIndex: CurrentLevel, BranchDepth: 0) → Add zu PlacedRooms

  3. BuildMainPath

4.5 RegisterRoomSockets (Hilfsfunktion, NEU)

Diese Funktion liest die Socket-Definitionen eines Raums und füllt OpenSocketsBuffer mit S_OpenSocketData-Einträgen. Sie schreibt nicht direkt in AllTrackedSockets das übernimmt der Aufrufer nach dem Random Pick, damit PathType für jeden Socket korrekt gesetzt wird.

Umsetzung: Erstelle eine neue Funktion (nicht Custom Event) im BP_DungeonGenerator:

Inputs:

  • Room (Actor Object Ref)
  • BranchDepth (Integer, Default: 0)

Keine Outputs (schreibt in OpenSocketsBuffer, clearet ihn vorher).

Graph:

  1. Clear OpenSocketsBuffer
  2. → Room → GetSocketDefinitions (Message) → SocketDefinitions Array
  3. Branch: SocketDefinitions → Length > 0?
  4. → False ▶: Print String "WARNING: Room has no sockets!" → Return
  5. → True ▶: ForEachLoop über SocketDefinitions
  6. Loop Body ▶Make S_OpenSocketData:
    • OwnerRoom: Room (Input)
    • SocketId: Array Element → Break S_SocketDefinition → SocketId
    • SocketIndex: ForEachLoop → Array Index
    • PathType: Branch (immer wird nach dem Random Pick für den gewählten Socket auf MainPath korrigiert)
    • LevelIndex: CurrentLevel (Generator-Variable)
    • BranchDepth: BranchDepth (Input)
    • State: Open
  7. Add zu OpenSocketsBuffer
  8. ForEachLoop Completed ▶Return
RegisterRoomSockets (Room, BranchDepth)
  → Clear OpenSocketsBuffer
  → Room → GetSocketDefinitions (Message) → SocketDefinitions
  → Branch: Length > 0?
    False ▶ → Print "WARNING: Room has no sockets!" → Return
    True ▶ → ForEachLoop (SocketDefinitions)
      Loop Body ▶ → Make S_OpenSocketData
        ├─ OwnerRoom: Room
        ├─ SocketId: Element.SocketId
        ├─ SocketIndex: Array Index
        ├─ PathType: Branch
        ├─ LevelIndex: CurrentLevel
        ├─ BranchDepth: BranchDepth
        └─ State: Open
      → Add zu OpenSocketsBuffer
    Completed ▶ → Return

WICHTIG: AllTrackedSockets ist die einzige vollständige Socket-Datenbank. OpenSocketsBuffer ist nur ein temporärer Zwischenpuffer nach dem Random Pick werden alle Einträge aus dem Buffer mit korrekt gesetztem PathType in AllTrackedSockets eingetragen. MainPathFrontier und BranchFrontier sind Arbeits-Arrays für aktuell offene Kandidaten.

4.5.1 UpdateSocketState (Hilfsfunktion, NEU)

Diese Funktion wird an vielen Stellen benötigt. Sie findet einen bestimmten Socket in AllTrackedSockets und setzt seinen State.

Umsetzung: Erstelle eine neue Funktion im BP_DungeonGenerator:

Inputs:

  • TargetRoom (Actor Object Ref) Der Raum dessen Socket geändert wird
  • TargetSocketId (Name) Die Socket-ID die geändert wird
  • NewState (E_SocketStatus) Der neue State (Occupied, Closed, etc.)

Keine Outputs.

Graph:

  1. AllTrackedSocketsForEachLoop with Break
  2. Loop Body ▶ → Array Element → Break S_OpenSocketData
  3. Branch: OwnerRoom == TargetRoom AND SocketId == TargetSocketId?
  4. True ▶:
    • Array Element → Break S_OpenSocketData → alle Felder einzeln rausholen
    • Make S_OpenSocketData mit allen gleichen Werten, aber State = NewState
    • Set Array Elem (Target: AllTrackedSockets, Index: ForEachLoop Array Index, Item: der neue S_OpenSocketData)
    • Break (ForEachLoop beenden)
  5. False ▶: → weiter (nächste Iteration)
UpdateSocketState (TargetRoom, TargetSocketId, NewState)
  → AllTrackedSockets → ForEachLoop with Break
    Loop Body ▶ → Break S_OpenSocketData
      → Branch: OwnerRoom == TargetRoom AND SocketId == TargetSocketId?
        True ▶ → Make S_OpenSocketData (alle Felder kopieren, State = NewState)
               → Set Array Elem (AllTrackedSockets, Array Index, neuer Eintrag)
               → Break
        False ▶ → (weiter)

UE Blueprint Details für Set Array Elem:

  • Rechtsklick in den Graph → "Set Array Elem"
  • Target: AllTrackedSockets (Variable-Getter, als Referenz)
  • Index: ForEachLoop → Array Index
  • Item: Der neue S_OpenSocketData Struct
  • Size to Fit: NEIN (der Index existiert bereits)

4.6 BuildMainPath

Umsetzung als While-Loop:

While Loop Condition:

  • AND: MainPathFrontier Length > 0 UND CurrentRoomsOnLevel < MaxRoomsOfLevel

Loop Body ▶:

  1. → MainPathFrontier Get (Index 0)Set CurrentSocket
  2. → MainPathFrontier Remove Index 0
  3. FindCompatibleRoom (OpenSocket: CurrentSocket, ExcludeTypes: [Entrance, Exit, PortalIn, PortalOut, DeadEnd])
    • Details siehe Teil 5.1
  4. Branch (Success)

False ▶ (kein kompatibler Raum gefunden): 5. → Print String "No compatible room found for socket" (Debug) 6. → (While Loop nächste Iteration der nächste Socket in MainPathFrontier wird versucht)

True ▶ (Raum gefunden): 5. → SnapRoomToSocket (OpenSocket: CurrentSocket, NewRoomClass: FoundRoomClass, NewSocketIndex: MatchingSocketIndex, NewSocketId: MatchingSocketId)

  • Details siehe Teil 5.2
  1. Branch (Success)

False ▶ (Snap fehlgeschlagen, z.B. Kollision): 7. → Print String "Snap failed (collision)" (Debug) 8. → (While Loop nächste Iteration)

True ▶ (Snap erfolgreich):

Schritt 7 Raum registrieren: 7. → Add SpawnedRoom zu PlacedRoomActors 8. → CurrentRoomsOnLevel + 1Set CurrentRoomsOnLevel 9. → Make S_PlacedRoomData (RoomActor: SpawnedRoom, RoomType: aus FindCompatibleRoom, PathType: MainPath, LevelIndex: CurrentLevel, BranchDepth: 0) → Add zu PlacedRooms

Schritt 10 Benutzten Socket auf Occupied setzen: 10. → UpdateSocketState (TargetRoom: CurrentSocket.OwnerRoom, TargetSocketId: CurrentSocket.SocketId, NewState: Occupied)

Schritt 11 Sockets des neuen Raums registrieren und verteilen (KRITISCH):

Ohne die folgende Regel verzweigt der MainPath unkontrolliert und wird zu einem Netz statt einem klaren Pfad. Die Regel ist: Genau 1 Socket geht auf MainPath, der Rest geht auf Branch.

Warum zufällig? "Ersten nehmen" wäre immer derselbe Socket (abhängig von der Reihenfolge der Arrow Components im Blueprint-Viewport). Der Zufalls-Pick sorgt dafür, dass der MainPath-Ausgang bei jedem Seed anders liegen kann. Das gilt für alle Räume gleichermaßen beim Startraum gibt es keinen Eingangs-Socket, bei allen anderen ist genau einer belegt.

MatchingSocketId kennen: MatchingSocketId ist die SocketId des Sockets am neuen Raum, der für das Andocken benutzt wurde. Du bekommst sie aus FindCompatibleRoom:

  • In FindCompatibleRoom hast du den SocketDefinitions-Array des Kandidaten durchsucht
  • MatchingSocketIndex ist der Array-Index des gewählten Sockets
  • MatchingSocketId = SocketDefinitions[MatchingSocketIndex].SocketId
  • Erweitere FindCompatibleRoom so, dass es auch MatchingSocketId (Name) zurückgibt (zusätzlich zu MatchingSocketIndex)

Umsetzung (gleiches Muster wie Schritt 9, nur mit Eingangs-Socket-Behandlung davor):

// Schritt 11a  Buffer füllen (RegisterRoomSockets cleared OpenSocketsBuffer selbst)
RegisterRoomSockets (Room: SpawnedRoom, BranchDepth: 0)
  → OpenSocketsBuffer enthält alle Sockets mit PathType: Branch, State: Open

// Schritt 11b  Eingangs-Socket aus Buffer herausnehmen und als Occupied in AllTrackedSockets eintragen
OpenSocketsBuffer → ForEachLoop with Break
  Loop Body ▶ → Branch: Element.SocketId == MatchingSocketId?
    True ▶  → Make S_OpenSocketData (alle Felder von Element, State: Occupied, PathType: Branch)
            → Add zu AllTrackedSockets
            → Remove (OpenSocketsBuffer, Array Index)
            → Break

// Schritt 11c  Zufälligen MainPath-Socket aus verbliebenem Buffer wählen
Branch: OpenSocketsBuffer Length > 0?
  False ▶ → Print "WARNING: New room has no free sockets!" → (weiter)
  True ▶  → Random Integer in Range from Stream (0, OpenSocketsBuffer Length - 1)
          → Set RandomPickedIndex

// Schritt 11d  Restlichen Buffer in AllTrackedSockets eintragen und verteilen
OpenSocketsBuffer → ForEachLoop
  Loop Body ▶ → Branch: Array Index == RandomPickedIndex?
    True ▶  → Array Element → Break S_OpenSocketData
            → Make S_OpenSocketData
                ├─ OwnerRoom, SocketId, SocketIndex, LevelIndex, BranchDepth, State: jeweiliger Pin von Break
                └─ PathType: MainPath  ← einzige Änderung
            → Add zu AllTrackedSockets
            → Add zu MainPathFrontier
    False ▶ → Array Element → Add zu AllTrackedSockets   (PathType bereits Branch, kein Break/Make nötig)
            → Array Element → Add zu BranchFrontier

Ergebnis pro Raum: Genau 1 Socket → Occupied (Eingang), genau 1 zufälliger Socket → MainPathFrontier (Ausgang), Rest → BranchFrontier.

Zusammenfassung Socket-Verteilung (gilt für ALLE Räume):

Schritt a  RegisterRoomSockets → OpenSocketsBuffer gefüllt (alle Branch, Open), AllTrackedSockets noch leer für diesen Raum
Schritt b  Eingangs-Socket aus Buffer → AllTrackedSockets (Occupied)    [entfällt beim Startraum]
Schritt c  Random Pick Index aus verbliebenem Buffer bestimmen
Schritt d  Buffer → AllTrackedSockets + Frontiers:
  ├─ Zufälliger Index → PathType: MainPath, MainPathFrontier
  └─ Alle anderen    → PathType: Branch,   BranchFrontier

While Loop Completed ▶PlaceEndRoom

4.7 PlaceEndRoom

  1. PlaceEndRoom ▶
  2. Branch: MainPathFrontier Length > 0?

False ▶ (Kein offener MainPath-Socket mehr): 3. → Print String "No open MainPath socket for end room!" 4. → RetryLevel (siehe Teil 4.11)

True ▶: 3. → MainPathFrontier Get (Index 0)Set CurrentSocket 4. → Branch: CurrentLevel == TotalLevels?

True ▶ (Letztes Level Exit): 5. → FindCompatibleRoom (OpenSocket: CurrentSocket, ExcludeTypes: [Entrance, PortalIn, PortalOut, General, Corridor, DeadEnd])

  • Damit bleibt nur Exit übrig als gültiger Typ
  1. Branch (Success)
    • False ▶: Print String "No Exit room found!" → RetryLevel
  2. → True ▶: SnapRoomToSocket (OpenSocket: CurrentSocket, NewRoomClass: FoundRoomClass, NewSocketIndex: MatchingSocketIndex, NewSocketId: MatchingSocketId)
  3. Branch (Success)
    • False ▶: Print String "Exit snap failed!" → RetryLevel
  4. → True ▶:
  5. Set EndRoom = SpawnedRoom
  6. Add SpawnedRoom zu PlacedRoomActors
  7. Make S_PlacedRoomData (PathType: MainPath, RoomType: Exit, LevelIndex: CurrentLevel, BranchDepth: 0) → Add zu PlacedRooms
  8. UpdateSocketState (CurrentSocket.OwnerRoom, CurrentSocket.SocketId, Occupied)

Schritt 14 Alle Sockets des Exit-Raums registrieren: 14. → RegisterRoomSockets (Room: SpawnedRoom, PathType: MainPath, BranchDepth: 0)

  • Registriert alle Sockets des Exit-Raums in AllTrackedSockets mit State: Open
  1. → Den Eingangs-Socket des Exit-Raums auf Occupied setzen: → UpdateSocketState (TargetRoom: SpawnedRoom, TargetSocketId: MatchingSocketId, NewState: Occupied)
  • MatchingSocketId kommt aus FindCompatibleRoom (Schritt 5)
  • Übrige Sockets des Exit-Raums bleiben Open und werden später von SealOpenSockets mit WallBlockern verschlossen
  1. BuildBranches

False ▶ (Nicht letztes Level PortalOut): 5. → FindCompatibleRoom (OpenSocket: CurrentSocket, ExcludeTypes: [Entrance, PortalIn, Exit, General, Corridor, DeadEnd])

  • Damit bleibt nur PortalOut übrig als gültiger Typ
  1. Branch (Success)
    • False ▶: Print String "No PortalOut room found!" → RetryLevel
  2. → True ▶: SnapRoomToSocket (OpenSocket: CurrentSocket, NewRoomClass: FoundRoomClass, NewSocketIndex: MatchingSocketIndex, NewSocketId: MatchingSocketId) → Branch (Success)
    • False ▶: → RetryLevel
  3. → True ▶:
  4. Set EndRoom = SpawnedRoom
  5. Set LastPortalOut = SpawnedRoom
  6. Add SpawnedRoom zu PlacedRoomActors
  7. Make S_PlacedRoomData (PathType: MainPath, RoomType: PortalOut, LevelIndex: CurrentLevel, BranchDepth: 0) → Add zu PlacedRooms
  8. UpdateSocketState (CurrentSocket.OwnerRoom, CurrentSocket.SocketId, Occupied)

Schritt 14 Alle Sockets des PortalOut-Raums registrieren: 14. → RegisterRoomSockets (Room: SpawnedRoom, PathType: MainPath, BranchDepth: 0)

  • Registriert alle Sockets des PortalOut-Raums in AllTrackedSockets mit State: Open
  1. → Den Eingangs-Socket des PortalOut-Raums auf Occupied setzen: → UpdateSocketState (TargetRoom: SpawnedRoom, TargetSocketId: MatchingSocketId, NewState: Occupied)
  • MatchingSocketId kommt aus FindCompatibleRoom (Schritt 5)
  • Übrige Sockets des PortalOut-Raums bleiben Open → werden von SealOpenSockets verschlossen
  1. Set CurrentLevel = CurrentLevel + 1
  2. BuildLevel (nächstes Level starten)

WICHTIG: Branches werden NICHT nach PortalOut gebaut! Nur nach Exit (letztes Level). Dadurch sammelt BranchFrontier Sockets über ALLE Level hinweg, und BuildBranches baut sie am Ende alle auf einmal.

4.8 BuildBranches

Wird NUR nach dem Exit-Raum aufgerufen. Baut Abzweigungen über alle Level hinweg.

  1. BuildBranches ▶
  2. Branch: BranchFrontier Length > 0?
    • False ▶: Print String "No branch sockets available" → SealOpenSockets

WICHTIG: Array-Kopie erstellen! BranchFrontier wird während der Iteration verändert (neue Sockets werden hinzugefügt). In UE Blueprints darf man ein Array nicht verändern während man darüber iteriert.

  1. → True ▶: BranchFrontier → Copy in lokale Variable BranchWorkList (Rechtsklick auf BranchFrontier → Get → in neue lokale Variable ziehen, oder: Set BranchWorkList = BranchFrontier, dann Clear BranchFrontier)

Konkret:

Set BranchWorkList = BranchFrontier (kopiert das Array)
Clear BranchFrontier (leert das Original  neue Sockets aus Branch-Räumen kommen hier rein)
  1. ForEachLoop über BranchWorkList (NICHT über BranchFrontier!)

Loop Body ▶ (für jeden Branch-Startpunkt):

  1. Random Integer in Range (MaxBranchDepthMin, MaxBranchDepthMax) → Set MaxBranchDepth (lokale Variable)
  2. Set CurrentBranchDepth = 0 (lokale Variable)
  3. → Array Element → Set CurrentBranchSocket (lokale Variable, Typ: S_OpenSocketData)

Innere While-Loop (Condition: CurrentBranchDepth < MaxBranchDepth):

  1. Branch: CurrentBranchDepth == MaxBranchDepth - 1?

True ▶ (Letzter Raum der Branch DeadEnd platzieren):

  1. SnapRoomToSocket (OpenSocket: CurrentBranchSocket, NewRoomClass: BP_DungeonRoom_DeadEnd, NewSocketIndex: 0, NewSocketId: "Socket_Entry")
    • DeadEnd hat immer genau 1 Socket namens "Socket_Entry", daher Index 0 und Id fest
  2. Branch (Success)

False ▶ (DeadEnd Snap fehlgeschlagen): 11. → Print String "DeadEnd snap failed" → Break innere While-Loop (Branch abbrechen)

True ▶ (DeadEnd erfolgreich): 11. → Add SpawnedRoom zu PlacedRoomActors 12. → Make S_PlacedRoomData (PathType: Branch, RoomType: DeadEnd, LevelIndex: CurrentBranchSocket.LevelIndex, BranchDepth: CurrentBranchDepth + 1) → Add zu PlacedRooms 13. → UpdateSocketState (CurrentBranchSocket.OwnerRoom, CurrentBranchSocket.SocketId, Occupied) 14. → DeadEnd hat nur 1 Socket (Socket_Entry) → Eingangs-Socket registrieren:

  • Make S_OpenSocketData (OwnerRoom: SpawnedRoom, SocketId: "Socket_Entry", SocketIndex: 0, PathType: Branch, LevelIndex: CurrentBranchSocket.LevelIndex, BranchDepth: CurrentBranchDepth + 1, State: Occupied) → Add zu AllTrackedSockets
  1. Break innere While-Loop (Branch fertig)

False ▶ (Nicht letzter Raum normaler Branch-Raum):

  1. FindCompatibleRoom (OpenSocket: CurrentBranchSocket, ExcludeTypes: [Entrance, Exit, PortalIn, PortalOut, DeadEnd])
    • Liefert: FoundRoomClass, MatchingSocketIndex, MatchingSocketId, FoundRoomType, Success
  2. Branch (Success)
  • False ▶: Print String "No room for branch" → Break innere While-Loop
  1. → True ▶: SnapRoomToSocket (OpenSocket: CurrentBranchSocket, NewRoomClass: FoundRoomClass, NewSocketIndex: MatchingSocketIndex, NewSocketId: MatchingSocketId) → Branch (Success)
  • False ▶: Print String "Branch snap failed" → Break innere While-Loop
  1. → True ▶:

  2. Add SpawnedRoom zu PlacedRoomActors

  3. Make S_PlacedRoomData (PathType: Branch, RoomType: aus FindCompatibleRoom, LevelIndex: CurrentBranchSocket.LevelIndex, BranchDepth: CurrentBranchDepth + 1) → Add zu PlacedRooms

  4. UpdateSocketState (CurrentBranchSocket.OwnerRoom, CurrentBranchSocket.SocketId, Occupied)

  5. → Sockets des neuen Branch-Raums registrieren und verteilen:

  • SpawnedRoom → GetSocketDefinitions (Message) → SocketDefinitions
  • Lokaler Bool BranchContinued = false
  • ForEachLoop über SocketDefinitions:
    • Branch: SocketId == MatchingSocketId? (Eingangs-Socket, MatchingSocketId kommt aus FindCompatibleRoom in Schritt 9)
      • True ▶: Make S_OpenSocketData (State: Occupied) → Add zu AllTrackedSockets
      • False ▶: Branch: BranchContinued == false?
        • True ▶ (erster freier Socket → Branch weiterführen):
          • Make S_OpenSocketData (PathType: Branch, State: Open, BranchDepth: CurrentBranchDepth + 1) → Add zu AllTrackedSockets
          • Set CurrentBranchSocket = dieser neue S_OpenSocketData
          • Set BranchContinued = true
        • False ▶ (weitere Sockets → zur späteren Verarbeitung):
          • Make S_OpenSocketData (PathType: Branch, State: Open, BranchDepth: CurrentBranchDepth + 1) → Add zu AllTrackedSockets
          • Add zu BranchFrontier (wird in einer späteren Iteration verarbeitet, falls gewünscht)
  1. CurrentBranchDepth + 1 → Set CurrentBranchDepth
  2. → (Innere While-Loop nächste Iteration)

ForEachLoop Completed ▶:

  1. Branch: BranchFrontier Length > 0? (Wurden während des Branch-Baus neue Sockets gesammelt?)
  • True ▶: Optional: Nochmal eine Runde Branch-Bau starten (Rekursion/Loop), ODER: ignorieren und mit SealOpenSockets verschließen
  • False ▶: → SealOpenSockets

Empfehlung für den Prototyp: Neue BranchFrontier-Einträge aus dem Branch-Bau NICHT weiter verfolgen, sondern direkt zu SealOpenSockets gehen. Das verhindert unkontrolliertes Wachstum. Also nach ForEachLoop Completed immer → SealOpenSockets.

4.9 SealOpenSockets

Alle übrig gebliebenen offenen Sockets mit WallBlockern verschließen.

Die Funktion nutzt AllTrackedSockets als einzige Wahrheitsquelle.

  • Occupied = Socket wurde als Verbindung zwischen zwei Räumen benutzt
  • Open = Socket wurde nie verbunden (braucht WallBlocker)
  • Closed = Socket wurde bereits mit einem WallBlocker verschlossen
  1. SealOpenSockets ▶

Schritt 2 Offene Sockets sammeln: 2. → Lokales Array OpenSocketsToSeal (Typ: Array of S_OpenSocketData) erstellen 3. → AllTrackedSocketsForEachLoop 4. → Array Element → Break S_OpenSocketData → State 5. → Branch: State == Open? 6. → True ▶: Add Array Element zu OpenSocketsToSeal 7. → ForEachLoop Completed ▶: weiter mit Schritt 8

AllTrackedSockets → ForEachLoop
  Loop Body ▶ → Break S_OpenSocketData
    → Branch: State == Open?
      True ▶ → Add zu OpenSocketsToSeal
  Completed ▶ → (weiter)

Schritt 8 WallBlocker spawnen: 8. → ForEachLoop über OpenSocketsToSeal 9. → Array Element → Break S_OpenSocketData → OwnerRoom, SocketId 10. → OwnerRoom → GetSocketWorldTransformById (Message) (SocketId) → Location, Rotation, Success 11. → Branch (Success)

  • False ▶: Print String "WARNING: Socket not found for sealing" → weiter
  1. → True ▶: SpawnActor BP_WallBlocker
  • Location: Location vom Socket
  • Rotation: Rotation vom Socket
  1. Add WallBlocker zu SpawnedBlockers
  2. UpdateSocketState (OwnerRoom, SocketId, Closed)

ForEachLoop Completed ▶PlacePlayer

4.10 PlacePlayer

  1. PlacePlayer ▶
  2. Get Player Character (Player Index: 0)
  3. Branch: IsValid (Player Character)?
    • False ▶: Print String "No player character found!"
  4. → True ▶: StartRoom → GetActorLocationSet StartLocation
  5. SetActorLocation (Target: Player Character, New Location: StartLocation)
  6. Print String "Dungeon Generation Complete! Levels: " + TotalLevels + " Rooms: " + PlacedRooms Length

4.11 RetryLevel (NEU)

Wird aufgerufen wenn BuildMainPath oder PlaceEndRoom fehlschlägt.

  1. RetryLevel ▶
  2. RetryCount + 1Set RetryCount
  3. Branch: RetryCount > MaxRetries?

True ▶ (Zu viele Retries): 4. → Print String "FATAL: Level generation failed after " + MaxRetries + " retries!" 5. → (Hier enden optional: gesamten Dungeon neu starten mit GenerateDungeon)

False ▶ (Retry möglich): 4. → Print String "Retrying level " + CurrentLevel + " (Attempt " + RetryCount + ")"

Schritt 5 Räume des aktuellen Levels aufräumen:

Iteriere direkt über PlacedRooms (nicht PlacedRoomActors), weil PlacedRooms den LevelIndex enthält:

  1. → PlacedRooms → Rückwärts-ForLoop (Length - 1 → 0, step -1)
  2. → PlacedRooms Get (Index) → Break S_PlacedRoomData → LevelIndex, RoomActor
  3. Branch: LevelIndex == CurrentLevel?
  4. → True ▶: RoomActor → Destroy ActorRemove Index (PlacedRooms, Index)
  5. → ForLoop Completed ▶:

Schritt 10 Weitere Arrays bereinigen: 10. → PlacedRoomActors: Alle ungültigen Actors entfernen (Rückwärts-ForLoop → IsValid? → False → Remove Index) 11. → AllTrackedSockets: Alle Einträge mit LevelIndex == CurrentLevel entfernen (Rückwärts-ForLoop) 12. → MainPathFrontier → Clear 13. → BranchFrontier: Alle Einträge mit LevelIndex == CurrentLevel entfernen (Rückwärts-ForLoop)

Schritt 14 WallBlocker aufräumen (falls bereits Branches gebaut wurden): 14. → SpawnedBlockers → ForEachLoopDestroy ActorClear SpawnedBlockers

Schritt 15 Level neu starten: 15. → BuildLevel

Hinweis zur Array-Bereinigung: "Alle Einträge mit LevelIndex == X entfernen" ist in Blueprints am einfachsten mit einer Rückwärts-ForLoop:

PlacedRooms → Length - 1 → ForLoop (LastIndex to 0, step -1)
  Loop Body ▶ → PlacedRooms Get (Index) → Break → LevelIndex
    → Branch: LevelIndex == CurrentLevel?
      True ▶ → Remove Index (PlacedRooms, Index)

Rückwärts iterieren verhindert Index-Verschiebung beim Entfernen!


Teil 5: Funktionen

5.1 FindCompatibleRoom

Umsetzung: Erstelle eine neue Funktion im BP_DungeonGenerator.

Inputs:

  • OpenSocket (S_OpenSocketData) Der offene Socket an den angedockt werden soll
  • ExcludeTypes (Array of E_RoomType) Raumtypen die ausgeschlossen werden

Outputs:

  • FoundRoomClass (Actor Class Ref) Die Blueprint-Klasse des gewählten Raums
  • MatchingSocketIndex (Integer) Index des Sockets im neuen Raum der als Eingang dient
  • MatchingSocketId (Name) NEU Socket-ID des Eingangs-Sockets (für spätere Zuordnung)
  • FoundRoomType (E_RoomType) NEU Typ des gewählten Raums (für S_PlacedRoomData)
  • Success (Bool)

Schritt 1 Gültige Kandidaten aus der Data Table filtern:

Lokale Variablen (in der Funktion anlegen):

  • RowNames (Array of Name) Zeilennamen aus der Data Table
  • ValidCandidates (Array of S_RoomPoolEntry) gefilterte Kandidaten
  • TotalWeight (Float) Summe aller Weights
  • RandomPick (Float) zufälliger Wert zwischen 0 und TotalWeight
  • RunningWeight (Float) laufende Summe beim Iterieren
  • ChosenEntry (S_RoomPoolEntry) der gewählte Kandidat
  • TempCandidate (Actor Object Ref) temporär gespawnter Raum für Socket-Matching

Wichtig: In UE5 gibt es keinen "Get Data Table Rows"-Node (Plural). Stattdessen holst du zuerst alle Zeilennamen mit Get Data Table Row Names, dann iterierst du darüber und holst jede Zeile einzeln mit Get Data Table Row.

  1. Get Data Table Row Names (Table: RoomPoolTable) → Set RowNames
  2. ForEachLoop über RowNames
  3. Get Data Table Row (Table: RoomPoolTable, Row Name: Array Element) → hat zwei Exec-Pins und einen Data-Pin:
    • Row Found ▶ (Exec) Zeile existiert
    • Row Not Found ▶ (Exec) Zeile nicht gefunden (einfach ignorieren, weiter zur nächsten Iteration)
    • Out Row (S_RoomPoolEntry) die Zeilendaten
  4. Row Found ▶: Out Row → Break S_RoomPoolEntry → RoomType, MinLevel, MaxLevel
  5. Branch: Drei Bedingungen müssen ALLE true sein (AND):
    • RoomType → Contains in ExcludeTypes Array → NOT (= RoomType ist NICHT ausgeschlossen)
    • MinLevel <= CurrentLevel
    • MaxLevel >= CurrentLevel OR MaxLevel == 0 (0 = unbegrenzt)
  6. → True ▶: Out Row → Add zu ValidCandidates
  7. → ForEachLoop Completed ▶: Branch (ValidCandidates Length > 0)
  8. → False ▶: Return (Success = false)
Get Data Table Row Names (RoomPoolTable) → RowNames
ForEachLoop (RowNames)
  Loop Body ▶ → Get Data Table Row (RoomPoolTable, Array Element)
    Row Found ▶ → Break S_RoomPoolEntry → RoomType, MinLevel, MaxLevel
      → Branch: NOT Contains(ExcludeTypes, RoomType)
                 AND MinLevel <= CurrentLevel
                 AND (MaxLevel >= CurrentLevel OR MaxLevel == 0)
        True ▶ → Add zu ValidCandidates
    Row Not Found ▶ → (nichts, nächste Iteration)
  Completed ▶ → Branch: ValidCandidates Length > 0?
    False ▶ → Return (Success = false)
    True ▶ → (weiter mit Schritt 9)

Schritt 9 Gewichtete Zufallswahl:

  1. Set TotalWeight = 0.0
  2. ForEachLoop über ValidCandidates → TotalWeight += Array Element → Weight
  3. Random Float in Range (0.0, TotalWeight) → Set RandomPick
  4. Set RunningWeight = 0.0
  5. ForEachLoop über ValidCandidates
  6. → RunningWeight += Array Element → Weight
  7. Branch: RunningWeight >= RandomPick?
  8. → True ▶: Array Element ist der gewählte Kandidat → Set ChosenEntry = Array Element → Break
Gewichtete Zufallswahl:
  TotalWeight = Sum aller Weights
  RandomPick = Random Float (0, TotalWeight)
  RunningWeight = 0
  ForEachLoop:
    RunningWeight += Weight
    Branch: RunningWeight >= RandomPick?
      True ▶ → ChosenEntry = Element → Break

Schritt 17 Socket-Matching (den passenden Eingangs-Socket am Kandidaten finden):

Wir müssen herausfinden, welcher Socket des neuen Raums als "Eingang" benutzt wird also an den offenen Socket des bestehenden Raums andockt.

Für den Prototyp gilt folgende einfache Regel: Nimm den ersten Socket des Kandidaten. Für den Prototyp reicht das, weil alle Räume gleich große Durchgänge haben und Richtungs-Matching noch nicht implementiert ist.

  1. → ChosenEntry → Break S_RoomPoolEntry → RoomClass → SpawnActor temporär bei (0, 0, 10000) → Set TempCandidate
  • Position weit oben, damit er nicht mit dem Dungeon kollidiert
  1. → TempCandidate → GetSocketDefinitions (Message) → SocketDefinitions
  2. Branch: SocketDefinitions Length > 0?
  • False ▶: TempCandidate → Destroy ActorReturn (Success = false)
  1. → True ▶: SocketDefinitions Get (Index 0) → Break S_SocketDefinition
  2. → TempCandidate → Destroy Actor
  3. Return Node die Output-Pins direkt am Return Node verdrahten:
  • FoundRoomClass ← ChosenEntry → Break S_RoomPoolEntry → RoomClass
  • MatchingSocketIndex ← 0 (Integer Literal)
  • MatchingSocketId ← Break S_SocketDefinition → SocketId (aus Schritt 20)
  • FoundRoomType ← ChosenEntry → Break S_RoomPoolEntry → RoomType
  • Success ← true (Boolean Literal)

Hinweis: In UE5 Funktionen haben die Outputs keine separaten Set-Nodes — sie sind direkt als Data-Pins am Return Node. Du ziehst die Daten-Verbindungen (grüne/blaue Drähte) direkt in die Pins des Return Nodes. Die Exec-Kette ist: SpawnActor ▶ → GetSocketDefinitions ▶ → Branch → True ▶ → Destroy Actor ▶ → Return.

Spätere Erweiterung Richtungs-Matching: Statt pauschal Socket 0 zu nehmen, könnte man die Richtung des offenen Sockets prüfen und den Socket des Kandidaten wählen, der in die entgegengesetzte Richtung zeigt (Nord → Süd, Ost → West). Das ist aber für den Prototyp nicht nötig.

5.2 SnapRoomToSocket

Diese Funktion dockt einen neuen Raum an einen offenen Socket eines bestehenden Raums an.

Umsetzung: Erstelle eine neue Funktion im BP_DungeonGenerator.

Inputs:

  • OpenSocket (S_OpenSocketData) Der offene Socket am bestehenden Raum
  • NewRoomClass (Actor Class Ref) Blueprint-Klasse des neuen Raums
  • NewSocketIndex (Integer) Index des Sockets am neuen Raum der als Eingang dient
  • NewSocketId (Name) NEU Socket-ID des Eingangs-Sockets am neuen Raum (für GetSocketWorldTransformById)

Outputs:

  • SpawnedRoom (Actor Object Ref)
  • Success (Bool)

Lokale Variablen (in der Funktion anlegen):

  • SocketWorldPos (Vector) Weltposition des offenen Sockets am bestehenden Raum
  • SocketWorldRot (Rotator) Weltrotation des offenen Sockets
  • TargetRotation (Rotator) Ziel-Rotation für den neuen Raum (mit 180°-Drehung)
  • TempRoom (Actor Object Ref) der temporär gespawnte neue Raum
  • TempSocketLocation (Vector) Weltposition des Eingangs-Sockets am neuen Raum
  • TempSocketOffset (Vector) Offset vom Actor-Origin zum Socket
  • RotatedOffset (Vector) TempSocketOffset rotiert um TargetRotation
  • FinalPosition (Vector) finale Spawn-Position des neuen Raums

Schritt 1: Socket-Weltposition holen (via Interface)

  1. → OpenSocket → Break S_OpenSocketData → OwnerRoom, SocketId
  2. → OwnerRoom → GetSocketWorldTransformById (Message) (SocketId) → Location, Rotation, Success
  3. Branch (Success)
    • False ▶: Print String "ERROR: Socket not found!" → Return (SpawnedRoom: None, Success: false)
  4. → Location → Set SocketWorldPos

Schritt 2: Ziel-Rotation berechnen (WARUM 180°?)

Wenn zwei Räume aneinander andocken, müssen ihre Sockets einander zugewandt sein. Ein Socket zeigt "nach außen" aus seinem Raum heraus. Der neue Raum muss also so rotiert werden, dass sein Eingangs-Socket in die entgegengesetzte Richtung des Dock-Sockets zeigt. Darum drehen wir um 180° auf der Z-Achse (Yaw).

  1. → Rotation → Combine Rotators (A: Rotation, B: (Pitch: 0, Yaw: 180, Roll: 0)) → Set TargetRotation

Schritt 3: Neuen Raum spawnen

  1. SpawnActor (Class: NewRoomClass, Location: (0, 0, 0), Rotation: (0, 0, 0)) → Set TempRoom
    • Wir spawnen bei 0,0,0 und bewegen danach so können wir den Socket-Offset korrekt berechnen

Schritt 4: Offset des neuen Sockets berechnen

Der neue Raum wurde bei (0,0,0) ohne Rotation gespawnt. Jetzt berechnen wir, wo sein Eingangs-Socket relativ zum Actor-Origin liegt:

  1. → TempRoom → GetSocketWorldTransformById (Message) (NewSocketId) → TempSocketLocation, TempSocketRotation, Success
    • Falls ById fehlschlägt (Success == false): Print String "ERROR: NewSocket not found!" → TempRoom → Destroy ActorReturn (SpawnedRoom: None, Success: false)
  2. → TempSocketLocation - (Subtract Vector) TempRoom → GetActorLocationSet TempSocketOffset
    • Das ist der Vektor vom Actor-Origin zum Socket, in Weltkoordinaten (bei Rotation 0)

Schritt 5: Offset rotieren

Da wir den Raum gleich um TargetRotation drehen werden, muss der Offset ebenfalls rotiert werden:

  1. → TempSocketOffset → Rotate Vector (Rotator: TargetRotation) → Set RotatedOffset

Schritt 6: Finale Position berechnen

Der Socket des neuen Raums soll genau auf SocketWorldPos liegen. Also: Finale Position = SocketWorldPos - RotatedOffset

  1. → SocketWorldPos - (Subtract Vector) RotatedOffset → Set FinalPosition

Schritt 7: Raum positionieren

  1. → TempRoom → SetActorLocationAndRotation (Location: FinalPosition, Rotation: TargetRotation, Teleport: true)

Schritt 8: Kollisionsprüfung

  1. → TempRoom → Get Overlapping Actors (kein Class Filter) → OverlappingActors Array
  2. → OverlappingActors → Remove (Item: TempRoom) ← MUSS in der Exec-Kette liegen!
  3. → OverlappingActors → Length> 0Branch

True ▶ (Kollision mit anderem Raum): 15. → Print String "Collision detected, destroying room" (Debug) 16. → TempRoom → Destroy Actor 17. → Return (SpawnedRoom: None, Success: false)

False ▶ (Keine Kollision Erfolg): 15. → Return (SpawnedRoom: TempRoom, Success: true)

Exec-Kette (KRITISCH alle müssen verbunden sein):

SpawnActor ▶ → GetSocketWorldTransformById ▶ → SetActorLocationAndRotation ▶
→ Get Overlapping Actors ▶ → Remove ▶ → Branch ▶ → (Destroy oder Return)

Teil 6: Raum-Blueprints

6.1 Vorhandene Räume anpassen

Für jeden Raum-Blueprint (Entrance, General, Corridor, PortalIn, PortalOut, Exit):

  1. Arrow Components umbenennen (Socket_North, Socket_South, Socket_East, Socket_West, oder Socket_Entry/Socket_Exit für Corridors)
  2. Variable SocketDefinitions hinzufügen (Typ: Array of S_SocketDefinition)
  3. Variable RoomType hinzufügen (Typ: E_RoomType, Default: je nach Raum)
  4. Construction Script umbauen (siehe Teil 2.3)
  5. Interface-Funktionen implementieren:
    • GetSocketDefinitions: SocketDefinitions Variable → Return Node
    • GetSocketWorldTransformById: Implementierung siehe Teil 2.4
    • GetRoomType: RoomType Variable → Return Node
  6. Entfernen (falls vorhanden):
    • SetIsOnMainPath, GetIsOnMainPath
    • SetBranchDepth, GetBranchDepth
    • GetSockets (ersetzt durch GetSocketDefinitions)
    • Variablen IsOnMainPath, BranchDepth, ConnectedRooms
  7. BoundingBox: Etwas kleiner als Boden (10-20 Units eingerückt), flach (nur Bodenhöhe), Collision Preset: OverlapAllDynamic

6.2 BP_DungeonRoom_DeadEnd erstellen

  • Neuer Actor Blueprint erstellen
  • Components:
    • DefaultSceneRoot
    • RoomMesh (Static Mesh Component ein Raum mit nur einem Eingang)
    • BoundingBox (Box Collision, kleiner als Boden, flach, OverlapAllDynamic)
    • Socket_Entry (Arrow Component zeigt zum Eingang hinaus)
  • Variablen: RoomType = DeadEnd, SocketDefinitions (Array of S_SocketDefinition)
  • Construction Script: Wie alle anderen Räume (Teil 2.3)
  • Interface implementieren: Wie alle anderen Räume (Teil 6.1 Schritt 5)
  • In DT_RoomPool eintragen: RoomClass: BP_DungeonRoom_DeadEnd, RoomType: DeadEnd, Weight: 1.0, MinLevel: 1, MaxLevel: 0

6.3 BP_WallBlocker erstellen (falls noch nicht vorhanden)

  • Neuer Actor Blueprint
  • Components:
    • DefaultSceneRoot
    • WallMesh (Static Mesh Component eine Wand die den Durchgang blockiert)
  • Kein Interface nötig, keine Variablen nötig
  • Wird nur von SealOpenSockets gespawnt

6.4 BoundingBox-Regeln

  • Etwas kleiner als der Raumboden (1020 Units eingerückt auf jeder Seite)
  • Nur Bodenhöhe (flach) Wände sollen NICHT kollidieren, sonst scheitert jeder benachbarte Raum
  • Collision Preset: OverlapAllDynamic
  • Collision Enabled: Query Only (keine Physik-Simulation)
  • Generate Overlap Events: Ja

Teil 7: Wichtige Details und Fallstricke

7.1 Append-Reihenfolge

  • Target (oberer Pin): Das Array das befüllt wird
  • Source (unterer Pin): Die neuen Daten
  • Vertauschung überschreibt statt anzuhängen!

7.2 Socket-Weltposition live berechnen

NIEMALS gespeicherte Positionen verwenden. IMMER GetSocketWorldTransformById/ByIndex auf dem OwnerRoom aufrufen. Grund: Wenn ein Actor bewegt wird (z.B. bei SetActorLocationAndRotation), werden gespeicherte Positionen ungültig.

7.3 Destroy Actor Target

Beim Retry: Target = Array Element aus PlacedRoomActors, NICHT self! "self" würde den Generator selbst zerstören.

7.4 WallBlocker beim Retry aufräumen

SpawnedBlockers Array durchgehen → Destroy Actor → Array Clear.

7.5 PortalIn Positionierung

PortalIn Position = LastPortalOut.GetActorLocation() minus Vector(0, 0, LevelVerticalOffset). Dadurch liegt jedes Level um LevelVerticalOffset tiefer als das vorherige.

7.6 Get Components by Class auf frischen Actors

Kann Length 0 zurückgeben direkt nach SpawnActor, besonders wenn das Construction Script noch nicht gelaufen ist. Deshalb immer Interface-Funktionen verwenden (GetSocketWorldTransformById), die auf die SocketDefinitions-Variable zugreifen.

7.7 Remove in Exec-Kette

Der Remove-Node bei Get Overlapping Actors muss in der Exec-Kette liegen:

SetActorLocationAndRotation ▶ → Get Overlapping Actors ▶ → Remove ▶ → Branch ▶

Ohne Exec-Verbindung wird Remove möglicherweise nicht ausgeführt bevor die Branch-Bedingung geprüft wird.

7.8 Return Node mit SpawnedRoom verbinden

Im SnapRoomToSocket: Der Success-Return-Node muss TempRoom auf dem SpawnedRoom Output-Pin haben! Sonst gibt die Funktion None zurück obwohl der Raum erfolgreich gespawnt wurde.

7.9 ForEachLoop mit Break vs. ohne Break

  • ForEachLoop (Standard): Iteriert über ALLE Elemente, kann nicht vorzeitig beendet werden
  • ForEachLoop with Break: Hat einen zusätzlichen "Break" Exec-Pin um die Schleife vorzeitig zu beenden
  • Verwende with Break in: UpdateSocketState, FindCompatibleRoom (gewichtete Wahl), GetSocketWorldTransformById
  • Verwende Standard in: RegisterRoomSockets, SealOpenSockets, Socket-Verteilung

7.10 Struct-Vergleiche in Blueprints

UE Blueprints können Structs nicht direkt mit == vergleichen. Um einen bestimmten Socket in AllTrackedSockets zu finden, vergleiche die einzelnen Felder (OwnerRoom == X AND SocketId == Y), nicht den gesamten Struct.

7.11 Array-Mutation während Iteration

NIEMALS ein Array verändern (Add, Remove, Clear) während du mit ForEachLoop darüber iterierst. Das führt zu undefiniertem Verhalten. Lösung: Vor der Iteration eine Kopie des Arrays erstellen und über die Kopie iterieren.


Teil 8: Test-Reihenfolge

Phase 1: Grundgerüst

  1. GenerateDungeon aufrufen → Print: Seed, TotalLevels, MaxRoomsOfLevel
  2. BuildLevel → Entrance spawnt bei (0,0,0) → Print "Entrance spawned"
  3. Print SocketDefinitions Length → muss > 0 sein
  4. Print MainPathFrontier Length → muss genau 1 sein
  5. Print AllTrackedSockets Length → muss == Anzahl Arrow Components am Entrance sein

Phase 2: Positionierung

  1. BuildMainPath → Erster Raum wird gespawnt → Print FinalPosition
  2. Positionen müssen verschieden sein (nicht alle bei 0,0,0)
  3. F8 im Play-Mode → Kamera freigeben → Räume visuell prüfen: Docken sie korrekt aneinander?
  4. Print CurrentRoomsOnLevel nach jedem Raum → muss hochzählen

Phase 3: Level-Übergang

  1. PlaceEndRoom → PortalOut spawnt → Print "PortalOut placed"
  2. CurrentLevel wird auf 2 erhöht
  3. PortalIn spawnt unter PortalOut → Print PortalIn Location (Z muss um LevelVerticalOffset niedriger sein)
  4. Zweites Level baut sich → Print Raumanzahl

Phase 4: Exit und Branches

  1. Letztes Level → Exit spawnt → Print "Exit placed"
  2. BuildBranches wird aufgerufen → Print BranchFrontier Length vor dem Bau
  3. Branches bauen sich → Print je Branch: Tiefe, Raumtyp
  4. DeadEnd am Ende jeder Branch → Print "DeadEnd placed"

Phase 5: Abschluss

  1. SealOpenSockets → Print Anzahl versiegelter Sockets
  2. WallBlocker an offenen Sockets sichtbar
  3. PlacePlayer → Spieler steht im Entrance-Raum → Print "Complete!"

Phase 6: Retry-Test

  1. Künstlich einen Fehler provozieren (z.B. MaxRoomsPerLevel = 1) → RetryLevel muss feuern
  2. Print Retry-Zähler → muss hochzählen
  3. Nach MaxRetries → Abbruch-Meldung

Debug-Empfehlungen

  • Print String mit RandomSeed ganz am Anfang (für Reproduzierbarkeit)
  • Print String mit FinalPosition in SnapRoomToSocket (sind die Positionen plausibel?)
  • Print String mit OwnerRoom → IsValid vor GetSocketWorldTransform (Raum noch vorhanden?)
  • Print String mit Arrow Count nach Get Components by Class (Sockets vorhanden?)
  • DrawDebugBox für jeden platzierten Raum an GetActorLocation (optional, zeigt Raumgrenzen)
  • Print String mit AllTrackedSockets Length nach jedem RegisterRoomSockets (wächst korrekt?)

Anhang: Ablauf-Zusammenfassung

BeginPlay
  └→ GenerateDungeon
       ├─ Seed, Levels, Rooms berechnen
       └→ BuildLevel (Level 1)
            ├─ Spawn Entrance
            ├─ RegisterRoomSockets → AllTrackedSockets
            ├─ Socket-Verteilung: 1→MainPathFrontier, Rest→BranchFrontier
            └→ BuildMainPath
                 ├─ While (Frontier>0 AND Rooms<Max):
                 │    ├─ FindCompatibleRoom
                 │    ├─ SnapRoomToSocket
                 │    ├─ UpdateSocketState (Occupied)
                 │    └─ Socket-Verteilung: 1→Occupied, 1→MainPath, Rest→Branch
                 └→ PlaceEndRoom
                      ├─ Nicht letztes Level → PortalOut
                      │    ├─ CurrentLevel + 1
                      │    └→ BuildLevel (nächstes Level)
                      │         ├─ Spawn PortalIn (unter LastPortalOut)
                      │         └→ BuildMainPath → PlaceEndRoom → ...
                      └─ Letztes Level → Exit
                           └→ BuildBranches
                                ├─ Kopie von BranchFrontier
                                ├─ ForEach Branch-Socket:
                                │    ├─ While (Depth < MaxDepth):
                                │    │    ├─ Letzter Raum → DeadEnd
                                │    │    └─ Sonst → FindCompatibleRoom + Snap
                                │    └─ Break bei Fehler
                                └→ SealOpenSockets
                                     ├─ Alle Open Sockets → WallBlocker
                                     └→ PlacePlayer
                                          └─ Spieler bei Entrance platzieren