Vierzehnte Registry: NPCs sind keine Wesen — kein Kampfzustand, keine Initiative, keine Attribute. Ihre Antwortliste ist flach; was erst nach einer Antwort erscheint, ist eine Option mit einer Bedingung. - open_shop als elfte Aktion (game/ setzt eine Absicht, die UI schiebt) - Talk/Choose/Buy/Sell, Talk wirkt auf das eigene Feld wie PickUp - Preisspanne statt Goldkonto beim Haendler, Charisma in der Preisfunktion - Warenkorb einmal bei der Etagenerzeugung gezogen - Stapelregel aus Ticket 07 endlich gebaut: 50-120 Gold war 120 Eintraege - Dialog- und Shop-Screen, NPC-Symbol im Grid
11 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.
Befund aus M4: Die Regel war bis dahin nur beschrieben. ItemInstance hatte kein count, und der
Beutezieher schob für count = "50-120" bis zu 120 einzelne Einträge ins Inventar — 120 Zeilen im
Inventar-Screen, und ein Kauf hätte 45 davon einzeln entfernen müssen. ItemInstance trägt jetzt ein
count, und die Stapelregel wird beim Aufheben ausgeführt statt nur beschrieben.
Damit bedeutet auch stackable aus Ticket 02 zum ersten Mal etwas — das Feld stand seit M1 in den
Daten und hat nie jemand gelesen.
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.
Nachtrag aus Ticket 14
enemies ist der laufende Kampf, nicht die Etage. Das stand oben nie ausdrücklich da und wäre
sonst falsch geraten worden: Bei Grid-Erkundung mit Menü-Kampf griffe sonst der Skelettkrieger drei
Räume weiter jeden Zug mit an. Leer bedeutet kein Kampf; gefüllt wird die Liste von der Etage (10).
Zwei neue Felder am Creature:
initiative: i32— einmal beim Betreten des Kampfes gewürfelt. Bewusst ein Zahlenwert am Wesen und keine Reihenfolgeliste ausCreatureRef: Die wäre eine zweite Indexliste mit demselben Ungültigkeitsproblem beim Tod eines Gegners.cooldowns: HashMap<AbilityId, u32>— Abklingzeiten in Zügen.
Dazu der RNG-Seed als Feld in Game, damit Nachladen nicht neu würfelt.
Ticket 15 beantwortet außerdem die Frage, die 07 offen an es weitergereicht hatte — wo der
EXP-Fortschritt wohnt: current_exp und unspent_points: u32 gehören an Game, nicht an
Creature. base.level bleibt am Wesen, weil Gegner echte Level haben und die Etagen-Skalierung sie
liest; Erfahrung und unverteilte Punkte hat dagegen ausschließlich der Held, und ein Feld, das für
99 % der Exemplare bedeutungslos ist, schriebe der Save für jeden Gegner mit.
base.level ist dabei die einzige Quelle für Punkte; attribute(wer, "level") darf durch
Modifikatoren höher ausfallen, ohne welche zu schenken.
Und die Antwort auf die Frage, die 07 offen an 14 gereicht hatte: Tote Gegner werden am Ende von
Game::execute entfernt, nach dem Leerarbeiten der Event-Warteschlange. Innerhalb eines Zuges
bleiben Indizes stabil.
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.