9.1 KiB
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 zuusize-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
Gamehängen), und wie wird das aufgelöst?
Ergebnis: Skizze der Structs mit Besitzverhältnissen und Referenzform.
Answer
Begriffe im Glossar. 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
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:
[[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
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.
Nachtrag aus Ticket 08
Die Lesesignatur trägt einen optionalen Gegner:
Game::attribute(&self, who: CreatureRef, attr: Attribute, against: Option<CreatureRef>, reg: &Registries) -> f32
Ohne ihn ist ein bedingter Modifikator aus 01 („+5 Schaden gegen Untote") beim Auslesen von damage
nicht auswertbar — die Funktion wüsste nicht, gegen wen geschlagen wird. Der Charakterbogen gibt
None.
Dazu kommt events: Vec<Event> als Feld in Game: Ticket 08 arbeitet Events als Warteschlange ab,
weil synchrones Feuern über self.quests iterieren müsste, während self.apply erneut läuft.
Auflagen an andere Tickets
- 08 Event- und Trigger-System: Form von
QuestProgress; das Bedingungsvokabular, das 01 bereits angefordert hat. - 09 Save-Schema: serialisiert
Game.ItemInstanceschreibt 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 derVecnicht; 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.