167 lines
8.5 KiB
Markdown
167 lines
8.5 KiB
Markdown
# 07 — Game-State-Baum
|
||
|
||
Type: grilling
|
||
Status: resolved
|
||
Blocked by: 01, 02
|
||
|
||
## Question
|
||
|
||
Wie sieht der Weltzustand als Rust-Datenstruktur aus?
|
||
|
||
Entschieden ist: ein `Game`-Ownership-Baum mit Verweisen über IDs oder Indizes, kein ECS. Zu klären:
|
||
|
||
- Was besitzt was? Wo leben Held, aktuelle Etage, Räume, Gegner, Bodenloot, Questfortschritt?
|
||
- Trennung von unveränderlichen Definitionen (aus Mods geladen) und veränderlichem Laufzeitzustand:
|
||
zwei getrennte Strukturen, oder eine mit Verweisen in den Katalog?
|
||
- Referenzform: `String`-ID überall, oder beim Laden zu `usize`-Index auflösen? Was kostet das beim Speichern?
|
||
- Wo werden aktive Statuseffekte gehalten — am Träger oder in einer zentralen Liste?
|
||
- Welche Operation kämpft absehbar mit dem Borrow-Checker (Gegner greift Held an, während beide im selben
|
||
`Game` hängen), und wie wird das aufgelöst?
|
||
|
||
Ergebnis: Skizze der Structs mit Besitzverhältnissen und Referenzform.
|
||
|
||
## Answer
|
||
|
||
Begriffe im [Glossar](../../../CONTEXT.md). Dieses Ticket entscheidet **Besitz und Verweisform** —
|
||
nicht die vollständige Feldliste, die kommt aus 14, 15 und 16.
|
||
|
||
### Definitionen und Zustand sind getrennt
|
||
|
||
`Registries` (aus Mods geladen, unveränderlich) lebt **neben** `Game`, nicht darin. Signaturen werden
|
||
durchgängig `fn irgendwas(&mut self, reg: &Registries)`.
|
||
|
||
Der Grund ist ein Rust-Fakt, kein Geschmack: Läge `registries` als Feld in `Game`, kollidierte
|
||
`game.player.angreifen(&game.registries)` — ein `&mut`-Borrow auf ein Feld plus ein `&`-Borrow auf ein
|
||
anderes geht innerhalb einer Funktion, aber nicht über einen Methodenaufruf auf `&mut self` hinweg.
|
||
Das ist kein Randfall, sondern jeder zweite Aufruf im Kampf. Ein Screen bekommt entsprechend
|
||
`(&mut Game, &Registries)`, wie 06 es vorzeichnet.
|
||
|
||
Laufzeitobjekte **verweisen** per `String`-ID, sie kopieren nicht. Kopien würden bedeuten, dass ein
|
||
`overrides` aus Ticket 03 an bereits aufgesammelten Gegenständen vorbeigeht und der Save jede
|
||
Definition mitschreiben müsste.
|
||
|
||
Keine `usize`-Indizes und keine internierten Handles: Bei ein paar hundert Registry-Einträgen und
|
||
*einer* Handlung pro Tastendruck kaufen sie Geschwindigkeit, die niemand messen kann, und bezahlen sie
|
||
mit einer Auflösungsschicht in beide Richtungen plus einer neuen Fehlerklasse — Indizes hängen an der
|
||
Ladereihenfolge, und die ändert sich bei jedem installierten Mod. Die Decke: Taucht je eine heiße
|
||
Schleife auf, wird an *dieser* Stelle interniert, nicht global vorsorglich.
|
||
|
||
### Der Baum
|
||
|
||
```rust
|
||
struct Game {
|
||
player: Creature,
|
||
enemies: Vec<Creature>,
|
||
floor: Floor, // Innenbau gehoert Ticket 10
|
||
floor_number: u8,
|
||
quests: Vec<QuestProgress>, // Form gehoert Ticket 08
|
||
turn: u64,
|
||
}
|
||
|
||
struct Creature {
|
||
name: String,
|
||
base: Attributes, // Katalog aus Ticket 01
|
||
health: f32, stamina: f32, // Zustand, getrennt von den Attributen
|
||
options: Vec<OptionId>, // Rasse, Klasse
|
||
inventory: Vec<ItemInstance>,
|
||
equipment: HashMap<SlotId, ItemInstance>,
|
||
tags: Vec<String>,
|
||
modifiers: Vec<ActiveModifier>, // nur befristete
|
||
}
|
||
|
||
struct ItemInstance { def: ItemId, affixes: Vec<AffixId> }
|
||
|
||
enum CreatureRef { Player, Enemy(usize) }
|
||
```
|
||
|
||
**Ein `Creature` für Held und Gegner.** Das Glossar spricht durchgehend von Wesen; zwei Typen hießen,
|
||
die Kampfformeln aus 14 zweimal zu schreiben oder ein `trait Combatant` mit genau zwei
|
||
Implementierungen einzuziehen — die Abstraktion, die Ticket 02 beim `ItemType` schon abgeräumt hat.
|
||
|
||
**Held und Gegner sind zwei Felder, keine gemeinsame `Vec`.** Der Grund ist die Charter: Solo-Held,
|
||
kein Party-System. „Gegner greift Gegner an" existiert nicht, also kauft die gemeinsame `Vec` eine
|
||
Symmetrie, die das Spiel nie braucht, und bezahlt sie mit `split_at_mut` oder Herauskopieren in jeder
|
||
Kampfroutine. Zwei Felder sind ein disjunkter Feld-Borrow, den Rust erlaubt. Fällt die
|
||
Solo-Entscheidung je, ist das ohnehin ein Umbau der Kampfarchitektur — sie steht unter „Out of scope".
|
||
|
||
**Ein `Floor`, kein `Vec<Floor>`.** Abstiegs-Roguelike ohne Rückkehr: hält den Save klein und erspart
|
||
dem Generator aus 10 die Garantie, dass Rückwege begehbar bleiben. Die Folge wird bewusst in Kauf
|
||
genommen — der Spieler kann nicht zum Händler auf Etage 2 zurück, Händler müssen also als Prefab auf
|
||
jeder Etage auftauchen können.
|
||
|
||
`options` ist kein Beiwerk: Ohne das Feld hat der Held nach dem Charakterbau keine Rasse mehr, weil
|
||
Rassen- und Klassenmodifikatoren sonst nirgends herkommen.
|
||
|
||
### Instanzzustand und die sechste Registry
|
||
|
||
Zwei Exemplare desselben Gegenstands dürfen sich unterscheiden. Eine Instanz trägt aber keine frei
|
||
zusammengesetzten Modifikatoren, sondern **Affix-IDs** — und Affixe sind eine neue, moddbare Registry
|
||
neben den fünf aus Ticket 02:
|
||
|
||
```toml
|
||
[[affixes]]
|
||
id = "core:flaming"
|
||
name = "Flammend" # -> "Flammendes Eisenschwert"
|
||
price_factor = 1.5
|
||
modifiers = [ { attribute = "damage", flat = 4 } ]
|
||
```
|
||
|
||
Instanzzustand kostet an drei Stellen, und der Verweis auf Affixe macht zwei davon klein: Der **Save**
|
||
bleibt eine Liste von IDs (`{ id = "core:iron_sword", affixes = ["core:flaming"] }`) statt
|
||
serialisierter Modifikator-Strukturen, und die **Preisbildung** ist `base_price × Π price_factor`.
|
||
Dazu sind Affixe Inhalt — ein Modder erfindet „Vampirisch", ohne dass die Engine davon weiß.
|
||
Strukturell ist ein Affix dasselbe wie eine Charakteroption: benannte Modifikatorliste plus Metadaten.
|
||
|
||
Mit frei zusammengesetzten Modifikatoren je Exemplar könnte ein zufällig erzeugtes Schwert theoretisch
|
||
alles, praktisch aber nichts, das ein Modder benennen oder ein Händler bepreisen könnte — und die
|
||
Anzeige hätte keinen Namen für das, was da liegt.
|
||
|
||
**Stapelregel:** `stackable` **und** gleiche `def` **und** gleiche `affixes`. Ein Vergleich zweier
|
||
`Vec<String>`, eine Zeile, verbietet nichts. Affixe an ausrüstbare Gegenstände zu binden wäre eine
|
||
Regel, die dem Modder erklärt, was er darf — Bevormundung, die 02 abgeräumt hat. Dass ein Trank mit
|
||
Zufallsaffix nicht mehr stapelt, ist die logische Folge und das Problem dessen, der ihn so baut.
|
||
|
||
### Ein Eingang, ein Ausgang
|
||
|
||
```rust
|
||
Game::apply(&mut self, action: &Action, source: CreatureRef, target: CreatureRef, reg: &Registries)
|
||
Game::attribute(&self, who: CreatureRef, attr: Attribute, reg: &Registries) -> f32
|
||
```
|
||
|
||
`CreatureRef` fällt direkt aus der Zwei-Felder-Entscheidung: Es gibt keinen gemeinsamen Index mehr,
|
||
also braucht eine Aktion eine Adresse.
|
||
|
||
Der **Lesezugang liegt auf `Game`, nicht auf `Creature`**. Ticket 01 hat entschieden, dass ein
|
||
Modifikator mit `target = "all_enemies"` beim Träger wohnt, damit die Aura mit ihm stirbt. Folglich
|
||
braucht das Lesen eines Gegner-Attributs Zugriff auf die Modifikatoren des Helden — ein `Creature`
|
||
kann seine eigenen Attribute gar nicht ausrechnen. Zwei Rechenwege für dieselbe Formel aus 01 wären
|
||
ein Bug, der sich als Balancing-Problem tarnt: Wer `creature.attribute()` direkt aufruft, weil es
|
||
bequemer aussieht, vergisst die Aura.
|
||
|
||
Ein Attributzugriff läuft die vier Quellen jedes Mal ab: `base` → `options` → `equipment` samt Affixen
|
||
→ befristete `modifiers`. **Kein Cache.** Das passiert pro Tastendruck, nicht pro Frame. Die Decke:
|
||
Liest 14 dasselbe Attribut in einer Schleife zwanzigmal, wird *dort* einmal vorberechnet, statt hier
|
||
vorsorglich ein Invalidierungsproblem zu bauen.
|
||
|
||
Modifikatoren werden ausdrücklich **nicht** beim Ausrüsten in `modifiers` hineinkopiert. Das machte
|
||
das Lesen billiger und das Schreiben gefährlich: Jedes Ablegen müsste exakt die richtigen Einträge
|
||
wieder herausoperieren, und ein Affix-Wechsel ginge an der Kopie vorbei.
|
||
|
||
### Auflagen an andere Tickets
|
||
|
||
- **08 Event- und Trigger-System**: Form von `QuestProgress`; das Bedingungsvokabular, das 01 bereits
|
||
angefordert hat.
|
||
- **09 Save-Schema**: serialisiert `Game`. `ItemInstance` schreibt sich als `{ id, affixes }`.
|
||
- **10 Raum-Prefab-Format**: Innenbau von `Floor` — Grid, Räume, Bodenloot, Ausgänge. Dazu der
|
||
Händlerraum als Prefab, das auf jeder Etage auftauchen kann.
|
||
- **14 Kampfformeln und Rundenablauf**: Aktionskatalog und Ziel-Auflösung — und der **Aufräumzeitpunkt
|
||
für tote Gegner**. Ein `CreatureRef::Enemy(i)` überlebt das Entfernen aus der `Vec` nicht; wann
|
||
aufgeräumt wird, entscheidet der Rundenablauf.
|
||
- **16 Loot-Tabellen**: wer Affixe beim Loot vergibt und mit welcher Gewichtung.
|
||
|
||
### Folgen für bestehenden Code
|
||
|
||
`Stats` zerfällt in `Attributes` (Katalog aus 01) und Zustandsfelder — heute stehen `max_health` und
|
||
`current_health` nebeneinander in derselben Struktur. `Player` wird zu `Creature`. Neben `Item` als
|
||
Definition entsteht `ItemInstance` als Laufzeitform.
|