Reaktive & dynamische Dialoge und ein Intro mit besserem Tempo

Hinweis: Dieser Blogpost wurde maschinell aus dem englischen Original übersetzt. Zum englischen Original.

Überblick

Der Oktober war ein schwieriger Monat. Ein Todesfall in der Familie und die Unterstützung und Fürsorge danach gingen vor. Ich konnte trotzdem an den Pacing-Problemen im Early Game arbeiten. Das waren vor allem Dialogänderungen, eine neue Reihenfolge bei den Freischaltungen und Playtesting.

Die gute Nachricht ist, dass der Einstieg in Exofactory jetzt deutlich spannender ist. Weniger Kino-Intro im Stil von "In einer Welt, in der du eine verlorene KI spielst" und mehr so ein "Ich bin eine verlorene KI und muss Sachen machen!" Vibe.

Dialoge und Freischaltbedingungen zu bearbeiten ergibt keinen suuuuuper interessanten Blog-Content. Also dachte ich mir, ich nutze die Gelegenheit und erkläre mal Exofactorys reaktives, dynamisches Dialogsystem.

Es ist ziemlich cool und komplett datengetrieben.

Wie immer: Wenn du das Spiel interessant findest oder meine Arbeit unterstützen möchtest, hilft es sehr, das Spiel auf Steam auf die Wunschliste zu setzen.

Das Dialogsystem im Überblick

Das erste abgebaute Erz löst eine Freischaltung und den Dialog aus.

In Exofactory spielst du eine KI, die ständig nachdenkt und über sich selbst reflektiert. Sie kommentiert, was beim Spielen passiert, und der Spielfortschritt ist eng mit dieser Selbstreflexion verknüpft.

Der Tutorial-Dialog ist einfacher, da liegt alles in einer riesigen RON-Datei pro Sprache. Fürs Tutorial passt das, es läuft von Anfang bis Ende in einer festen Reihenfolge ab. Nach dem Tutorial ist der Dialog aber eigentlich nur noch ein Haufen kleiner, voneinander unabhängiger Events, die in so ziemlich jeder Reihenfolge ausgelöst werden können.

Da ich, wann immer es geht, einen datenorientierten Ansatz bevorzuge, habe ich nach einer schöneren Möglichkeit gesucht, das aufzubauen, und am Ende eine Idee von Linux geklaut.

Von Linux geklaut

Unter Linux (oder den meisten anderen modernen Betriebssystemen wie den BSDs) gibt es .d-Verzeichnisse wie /etc/nginx/conf.d/ oder /etc/sysctl.d/. Im Grunde teilt man die Config in viele kleine Häppchen auf, statt alles in eine riesige Datei zu packen. Mit diesem Pattern können Linux-Admins Änderungen in kleinere, kontrollierte Stücke zerlegen. Man muss nicht über die Anzahl der nginx-Worker nachdenken, wenn man einen neuen Reverse-Proxy-Endpunkt hinzufügt.

Ich bin ein großer Fan von diesem Pattern. Events lassen sich unabhängig voneinander hinzufügen oder entfernen, und das ist sauber im Code und sauber in der Git-History.

Der ganze Dialog und die zugehörigen Metadaten sind in sich geschlossen und simpel.

assets/core/event_data/dialogue/tier1/
  discover_iron_ingot.d_group.ron
  make_zinc_ingot.d_group.ron
  open_furnace_window.d_group.ron
  unlock_screws.d_group.ron
  ...

Wie eine d_group-Datei aussieht

Jede Datei hat zwei Teile, die Metadaten und den eigentlichen Dialog. Hier sind die Metadaten für den Moment, in dem das erste Eisenerz abgebaut wird:

metadata: (
    id: "tier1.discover_iron_ingot",
    set: Tier1,
    watch_keys: [
        MemoryGroup(Items),
    ],
    conditions: [
        (
            t: MemoryAmount,
            c: (
                value: ItemsIronOre,
                comparison: GreaterOrEqual,
                threshold: 1,
            ),
        ),
    ],
    fire_mode: Once,
    before_dialogue: [
        (
            t: GrantRecipes,
            c: (
                recipes: [
                    HandcraftIronIngot,
                    SmeltIronIngot,
                ],
            ),
        ),
    ],
    after_dialogue: [],
),

Oben sehen wir sowohl, wann der Dialog ausgelöst wird, als auch, was er macht. Die watch_keys sagen "schau mich nur an, wenn sich bei den Items etwas ändert". Die conditions (vorerst unterstützt das Spiel nur inklusive Bedingungen) sagen, dass der Eisenerz-Zähler mindestens bei 1 stehen muss. fire_mode: Once heißt, dass er nie mehr als einmal ausgelöst wird. Im before_dialogue- oder after_dialogue-Teil können die Freischaltungen passieren, in diesem Fall werden zwei Eisenbarren-Rezepte freigeschaltet, direkt bevor die Zeile abgespielt wird.

Ich bin eigentlich nicht begeistert von dem t: und c: überall, aber es war die sauberste Umsetzung, die ich hinbekommen habe, wenn man bedenkt, wie serde Enum-Varianten serialisiert.

Der zweite Teil ist der Dialog. Jede Dialogzeile hat einen Pfad zu ihrer Audiodatei, den Dialogtext selbst und zusätzliche Metadaten wie die Länge der Audiodatei und die Pause vor und nach dem Audio. Wird der Dialog ausgelöst, werden sie einfach in der Reihenfolge aus der Datei abgespielt.

Die Dateien laden

Hier hat das Bevy-Ökosystem den Großteil der Arbeit für mich erledigt.

Mit bevy_common_assets kann ich festlegen, dass jede Datei, die auf d_group.ron endet, in ein DialogueTriggerAsset-Struct deserialisiert wird:

app.add_plugins(RonAssetPlugin::<DialogueTriggerAsset>::new(&[
    "d_group.ron",
]))

Dann lädt bevy_asset_loader einfach einen ganzen Tier / ein ganzes Verzeichnis an Dialogen als Collection:

#[derive(AssetCollection, Resource)]
pub struct Tier1TriggerAssets {
    #[asset(path = "core/event_data/dialogue/tier1", collection(typed))]
    pub triggers: Vec<Handle<DialogueTriggerAsset>>,
}

Und das ist so ziemlich der ganze Ladecode. Es gibt nirgendwo eine Liste von Dateien. Wenn eine Datei in diesem Verzeichnis liegt und auf d_group.ron endet, wird sie geladen.

Den Dialog auslösen

Das Spiel hält in einer Memories-Komponente fest, was du bisher gemacht hast (Gebäude, Items, Gebäudemenüs, Kamerawechsel und so weiter), zusammen mit Mood, also dem emotionalen Zustand der KI. Diese Datenstrukturen vergleiche ich mit einer Kopie vom letzten Dialog-Trigger-Check. Wenn sich zum Beispiel das Sub-Struct mit den Item-Zählern geändert hat, werden bei den Triggern, die Items beobachten, die Bedingungen geprüft.

Alles, was ausgelöst werden soll, landet in einer Queue. Wenn gerade kein anderer Dialog läuft, wird der nächste rausgeholt, die Before-Actions laufen, die Zeilen für deine Sprache werden ausgewählt, und das Ganze wird an die Untertitel- und Audiosysteme übergeben.

Das funktioniert auch im Multiplayer schön. Der Host hält einfach Mood und Memories aktuell und der Client reagiert einfach darauf.

Fazit

Ich bin froh, mal einen Grund zu haben, zu erklären, wie das Dialogsystem in Exofactory funktioniert. Die Umstände sind nicht schön, aber mit dem flotteren Intro und der anstehenden Terrain-Überarbeitung stehen spannende Zeiten bevor.

Wenn du Demo 2 spielen möchtest, sobald sie rauskommt, oder mich ganz allgemein unterstützen willst, kannst du das Spiel hier auf die Wunschliste setzen: