Zum Inhalt springen

cat posts/python-lernen-09-dateien-und-speichern.md

Python lernen (Teil 9): Dateien und Speichern

Dateien lesen und schreiben mit with, pathlib und JSON – damit der Dungeon seinen Spielstand speichern und später wiederherstellen kann.

In Teil 8 haben wir den Dungeon robuster gemacht. Ungültige Eingaben führen nicht mehr sofort zu einem Traceback, sondern werden kontrolliert behandelt.

Ein Problem bleibt aber: Sobald das Programm beendet wird, ist der aktuelle State weg.

Raum, Inventar, Lebenspunkte und eingesammelte Gegenstände existieren nur während der Programmausführung. Beim nächsten Start beginnt das Spiel wieder von vorn.

Heute geben wir dem Dungeon deshalb ein Gedächtnis. Wir speichern seinen State in einer Datei und stellen ihn später daraus wieder her.

Dabei lernen wir:

  • Dateien zu lesen und zu schreiben,
  • warum with beim Arbeiten mit Dateien wichtig ist,
  • wie pathlib Pfade als Objekte behandelt,
  • wie JSON strukturierte Daten speichert,
  • warum eigene Python-Objekte vor dem Speichern in einfache Daten umgewandelt werden müssen.

Dateien schreiben

Eine Datei kannst Du mit open(...) öffnen:

datei = open("notiz.txt", "w", encoding="utf-8")

Der erste Wert ist der Pfad zur Datei, der zweite der Modus. "w" steht für write, also Schreiben.

Anschließend können wir Text hineinschreiben:

datei.write("Hallo, Verlies.\n")
datei.close()

Mit close() wird die Datei geschlossen. Python leert dabei seine Schreibpuffer und gibt die zugehörigen Ressourcen frei.

So solltest Du Dateien im normalen Code allerdings nicht verwalten. Tritt zwischen open(...) und close() eine Exception auf, wird die letzte Zeile möglicherweise nie erreicht.

Dafür gibt es with.

Dateien mit with öffnen

Die übliche Schreibweise sieht so aus:

with open("notiz.txt", "w", encoding="utf-8") as datei:
    datei.write("Hallo, Verlies.\n")

Während des eingerückten Blocks steht die geöffnete Datei unter dem Namen datei zur Verfügung.

Sobald der Block verlassen wird, wird sie wieder geschlossen. Das gilt auch dann, wenn innerhalb des Blocks eine Exception auftritt.

with arbeitet hier mit einem Context Manager. Vereinfacht passiert:

  1. Datei öffnen.
  2. Mit der Datei arbeiten.
  3. Datei beim Verlassen des Blocks wieder schließen.

Ohne with müssten wir dieses Verhalten beispielsweise selbst mit try und finally absichern:

datei = open("notiz.txt", "w", encoding="utf-8")

try:
    datei.write("Hallo, Verlies.\n")
finally:
    datei.close()

Das funktioniert ebenfalls, ist für diesen Fall aber unnötig umständlich.

Beim normalen Arbeiten mit Dateien ist ein with-Block deshalb fast immer die bessere Wahl.

Wichtige Datei-Modi

Der Modus bestimmt, wie eine Datei geöffnet wird:

Modus Bedeutung
"r" lesen, die Datei muss existieren
"w" schreiben, neu anlegen oder vorhandenen Inhalt löschen
"a" schreiben und neue Daten am Ende anhängen
"x" exklusiv neu anlegen, Fehler falls die Datei existiert
"r+" lesen und schreiben, die Datei muss existieren

Besonders bei "w" solltest Du wissen, was passiert:

with open("notiz.txt", "w", encoding="utf-8") as datei:
    ...

Existiert notiz.txt bereits, wird ihr bisheriger Inhalt beim Öffnen verworfen.

Möchtest Du Text stattdessen ergänzen, verwendest Du "a":

with open("logbuch.txt", "a", encoding="utf-8") as datei:
    datei.write("Du betrittst die Halle.\n")

Der neue Text landet am Ende der Datei.

Bei Textdateien ein Encoding angeben

Für unsere Textdateien geben wir das Encoding ausdrücklich an:

encoding="utf-8"

Zum Beispiel:

with open("notiz.txt", "w", encoding="utf-8") as datei:
    datei.write("Schlüssel, Fackel, Südtor\n")

Ohne explizite Angabe verwendet open(...) das Standard-Encoding der aktuellen Umgebung. Das kann sich zwischen Betriebssystemen und Konfigurationen unterscheiden.

Gerade bei Texten mit Umlauten oder anderen Nicht-ASCII-Zeichen kann das später zu Problemen führen.

Unsere Dateien verwenden deshalb konsequent UTF-8.

Dateien lesen

Eine komplette Textdatei lässt sich mit .read() einlesen:

with open("notiz.txt", "r", encoding="utf-8") as datei:
    inhalt = datei.read()

print(inhalt)

"r" ist bei open(...) der Standardmodus. Du kannst ihn deshalb weglassen:

with open("notiz.txt", encoding="utf-8") as datei:
    inhalt = datei.read()

.read() liefert den gesamten Inhalt als String.

Für kleine Notizen, Konfigurationsdateien oder unseren Spielstand ist das problemlos. Bei sehr großen Dateien möchtest Du dagegen häufig nicht alles auf einmal in den Arbeitsspeicher laden.

Dateien zeilenweise lesen

Ein geöffnetes Textfile ist iterierbar. Wir können seine Zeilen deshalb direkt mit einer for-Schleife durchlaufen:

with open("logbuch.txt", encoding="utf-8") as datei:
    for zeile in datei:
        print(zeile, end="")

Jeder Schleifendurchlauf liefert eine Zeile.

Die Zeile enthält normalerweise bereits ihren Zeilenumbruch. Würden wir einfach

print(zeile)

verwenden, fügte print(...) noch einen weiteren hinzu und zwischen den eigentlichen Zeilen könnten Leerzeilen erscheinen.

Wenn Du den Zeilenumbruch für die weitere Verarbeitung entfernen möchtest, kannst Du beispielsweise schreiben:

with open("logbuch.txt", encoding="utf-8") as datei:
    for zeile in datei:
        zeile = zeile.rstrip("\n")
        print(zeile)

Ein einfaches .rstrip() würde dagegen nicht nur den Zeilenumbruch, sondern auch andere Whitespace-Zeichen am Ende entfernen.

pathlib: Pfade als Objekte

Bisher haben wir Dateipfade als Strings geschrieben:

"spielstand.json"

Für einfache Fälle reicht das. Sobald wir Pfade zusammensetzen, prüfen oder verändern wollen, ist pathlib angenehmer.

from pathlib import Path

Mit Path erzeugen wir ein Pfadobjekt:

speicherdatei = Path("spielstand.json")

Daran können wir verschiedene Operationen ausführen:

if speicherdatei.exists():
    print("Spielstand gefunden.")

print(speicherdatei.name)

Die Ausgabe des Namens lautet:

spielstand.json

Ein Path beschreibt dabei zunächst einen Pfad. Dass das Objekt existiert, bedeutet nicht automatisch, dass die Datei im Dateisystem bereits vorhanden ist.

Pfade zusammensetzen

Mit / kannst Du Pfade bequem zusammensetzen:

ordner = Path("daten")
speicherdatei = ordner / "spielstand.json"

speicherdatei ist danach wieder ein Path-Objekt und beschreibt den Pfad zur Datei spielstand.json im Verzeichnis daten.

pathlib übernimmt die plattformspezifischen Details für Dich. Du musst Pfade also nicht selbst aus Strings und "/" oder "\\" zusammenbauen.

Text direkt mit Path lesen und schreiben

Für kleine Textdateien bietet Path praktische Kurzformen:

from pathlib import Path

pfad = Path("notiz.txt")

pfad.write_text("Hallo, Verlies.\n", encoding="utf-8")
text = pfad.read_text(encoding="utf-8")

print(text)

write_text(...) öffnet die Datei, schreibt den Text und schließt sie wieder. read_text(...) erledigt dasselbe beim Lesen.

Für einfache Fälle ist das sehr bequem.

Sobald Du spezielle Datei-Modi, mehrere Schreibvorgänge oder zeilenweises Streaming brauchst, ist ein expliziter with-Block oft klarer:

with pfad.open("w", encoding="utf-8") as datei:
    datei.write("Hallo, Verlies.\n")

Path.open(...) entspricht weitgehend dem eingebauten open(...), arbeitet aber direkt mit dem Path-Objekt.

Warum reiner Text nicht reicht

Eine Notiz besteht einfach aus Text:

Du bist in der Halle.

Unser Spielstand besteht dagegen aus mehreren zusammengehörigen Daten:

  • Name des Spielers
  • Lebenspunkte
  • maximale Lebenspunkte
  • Gold
  • aktueller Raum
  • besuchte Räume
  • Inventar
  • Gegenstände, die noch in den Räumen liegen

Wir könnten uns dafür ein eigenes Format ausdenken:

name=Karl
hp=75
gold=50
ort=halle
inventar=fackel,schluessel

Damit müssten wir allerdings selbst festlegen, wie Zahlen, Listen, Sonderzeichen, fehlende Werte und verschachtelte Strukturen codiert und später wieder gelesen werden.

Für unseren Spielstand verwenden wir deshalb ein vorhandenes Datenformat: JSON.

JSON: strukturierte Daten als Text

JSON steht für JavaScript Object Notation. Es ist ein textbasiertes Format für strukturierte Daten.

Ein Spielstand könnte beispielsweise so aussehen:

{
  "version": 1,
  "ort": "halle",
  "spieler": {
    "name": "Karl",
    "hp": 75,
    "max_hp": 100,
    "gold": 50,
    "inventar": [
      "fackel",
      "schluessel"
    ]
  }
}

JSON ist menschenlesbar und wird von sehr vielen Programmiersprachen unterstützt.

Python bringt dafür das Standardmodul json mit:

import json

Eine zusätzliche Installation ist nicht nötig.

Python-Werte und JSON-Werte

Das json-Modul kann viele grundlegende Python-Werte direkt in JSON übersetzen:

Python JSON
dict Object
list / tuple Array
str String
int / float Number
True true
False false
None null

Ein Dictionary mit solchen Werten lässt sich direkt serialisieren:

daten = {
    "name": "Karl",
    "hp": 75,
    "inventar": ["Fackel", "Schlüssel"],
}

Ein eigenes Objekt wie Spieler, Raum oder Gegenstand kennt das json-Modul dagegen nicht automatisch:

json.dumps(spieler)

Das führt ohne zusätzliche Anpassungen zu einem TypeError.

Außerdem ist die Abbildung zwischen Python und JSON nicht in jedem Fall verlustfrei. Ein Tupel wird beispielsweise als JSON-Array gespeichert und beim späteren Laden zu einer Python-Liste.

Für unseren Spielstand verwenden wir deshalb bewusst einfache Dictionaries, Listen, Strings, Zahlen und Booleans.

JSON schreiben

Mit json.dump(...) schreibst Du JSON direkt in ein geöffnetes Textfile:

import json
from pathlib import Path

daten = {
    "name": "Karl",
    "hp": 75,
    "inventar": ["Fackel", "Schlüssel"],
}

pfad = Path("spielstand.json")

with pfad.open("w", encoding="utf-8") as datei:
    json.dump(
        daten,
        datei,
        ensure_ascii=False,
        indent=2,
    )
    datei.write("\n")

Mit

ensure_ascii=False

werden Nicht-ASCII-Zeichen wie ü nicht unnötig als Unicode-Escape-Sequenzen ausgegeben. Da wir die Datei mit UTF-8 öffnen, bleibt "Schlüssel" direkt lesbar.

indent=2 formatiert die Ausgabe mit Einrückungen.

Für das Programm wäre das nicht notwendig, für Menschen ist die Datei so aber wesentlich angenehmer zu lesen.

Der zusätzliche Zeilenumbruch

datei.write("\n")

ist für gültiges JSON ebenfalls nicht nötig. Bei Textdateien ist eine abschließende Newline aber eine verbreitete und praktische Konvention.

dump oder dumps?

Manchmal möchtest Du JSON nicht direkt in eine Datei schreiben, sondern zunächst als String erzeugen.

Dafür gibt es json.dumps(...):

text = json.dumps(
    daten,
    ensure_ascii=False,
    indent=2,
)

print(text)

Der Unterschied lässt sich leicht merken:

Funktion Ergebnis
json.dump(daten, datei) schreibt JSON in ein File-like Object
json.dumps(daten) erzeugt einen String

Zum Lesen gibt es das entsprechende Paar:

Funktion Eingabe
json.load(datei) liest JSON aus einem File-like Object
json.loads(text) liest JSON aus einem String

Das s steht bei dumps und loads also für die String-Variante.

JSON lesen

Mit json.load(...) lesen wir JSON aus einer geöffneten Datei:

with pfad.open(encoding="utf-8") as datei:
    daten = json.load(datei)

print(daten["name"])
print(daten["hp"])

Enthält die Datei syntaktisch kein gültiges JSON, löst der Decoder einen JSONDecodeError aus:

try:
    with pfad.open(encoding="utf-8") as datei:
        daten = json.load(datei)
except json.JSONDecodeError:
    print("Die Datei enthält kein gültiges JSON.")

Eine syntaktisch gültige JSON-Datei kann allerdings trotzdem inhaltlich falsch sein. Genau deshalb werden wir unseren Spielstand beim Laden zusätzlich überprüfen.

Spielstände brauchen stabile Kennungen

In unserem Dungeon gibt es Gegenstände wie:

Gegenstand("Fackel", "Eine rußige, aber noch brauchbare Fackel.")

Nur den sichtbaren Namen "Fackel" zu speichern, wäre auf Dauer unpraktisch.

Der Text könnte später geändert werden:

Fackel
Alte Fackel
Rußige Fackel

Oder zwei unterschiedliche Gegenstände könnten ähnliche Namen bekommen.

Deshalb geben wir Gegenständen eine stabile interne Kennung:

"fackel"
"schluessel"

Der Spieler sieht weiterhin "Fackel" und "Schlüssel". In der Speicherdatei verwenden wir dagegen die internen IDs.

Dafür erweitern wir die Klasse:

class Gegenstand:
    def __init__(self, kennung, name, beschreibung=""):
        self.kennung = kennung
        self.name = name
        self.beschreibung = beschreibung

Ein Gegenstand besitzt nun:

  • kennung: stabile interne Kennung
  • name: sichtbarer Name
  • beschreibung: Beschreibungstext

Für unseren kleinen Dungeon reicht diese Kennung aus.

In einem größeren Spiel wäre noch eine weitere Unterscheidung möglich: "fackel" könnte den Gegenstandstyp bezeichnen, während zwei einzelne Fackeln zusätzlich jeweils eine eigene Instanz-ID und eigenen State besitzen. Das brauchen wir hier noch nicht.

Eine Gegenstands-Datenbank im Code

Die bekannten Gegenstände beschreiben wir in einem Dictionary:

GEGENSTANDS_DATEN = {
    "fackel": {
        "name": "Fackel",
        "beschreibung": "Eine rußige, aber noch brauchbare Fackel.",
    },
    "schluessel": {
        "name": "Schlüssel",
        "beschreibung": "Ein schwerer Eisenschlüssel mit rostigen Zähnen.",
    },
}

Aus einer Kennung können wir damit jederzeit wieder ein Gegenstand-Objekt erzeugen:

def erstelle_gegenstand(kennung):
    daten = GEGENSTANDS_DATEN[kennung]

    return Gegenstand(
        kennung=kennung,
        name=daten["name"],
        beschreibung=daten["beschreibung"],
    )

Später könnte auch diese Definition aus einer Datei oder Datenbank kommen. Für unser Tutorial bleibt sie im Python-Code.

Warum wir auch den Zustand der Räume speichern müssen

Ein häufiger Fehler bei einem ersten Savegame besteht darin, nur den Spieler zu speichern:

{
  "ort": "bibliothek",
  "spieler": {
    "name": "Karl",
    "inventar": ["fackel"]
  }
}

Beim Laden würden wir anschließend die normale Dungeon-Welt neu erzeugen. Dort liegt die Fackel zunächst wieder in der Halle.

Das Ergebnis wäre:

  • Im Inventar liegt eine Fackel.
  • In der Halle liegt ebenfalls wieder eine Fackel.

Unser wiederhergestellter State wäre also falsch.

Deshalb speichern wir auch, welche Gegenstände noch in den einzelnen Räumen liegen:

"raeume": {
  "halle": {
    "gegenstaende": []
  },
  "bibliothek": {
    "gegenstaende": ["schluessel"]
  },
  "krypta": {
    "gegenstaende": []
  }
}

Damit lässt sich beim Laden nicht nur der Spieler, sondern auch der veränderte Zustand der Welt rekonstruieren.

Einen Spielstand als Dictionary erzeugen

Vor dem Speichern wandeln wir unseren Object State in JSON-kompatible Daten um.

Eine kleine Hilfsfunktion extrahiert zunächst die Kennungen der Gegenstände:

def gegenstands_kennungen(gegenstaende):
    """Gibt die stabilen Kennungen mehrerer Gegenstände zurück."""
    return [
        gegenstand.kennung
        for gegenstand in gegenstaende
    ]

Den vollständigen Spielstand erzeugen wir so:

def erstelle_spielstand(spieler, ort, raeume, besuchte_raeume):
    """Wandelt den aktuellen State in JSON-kompatible Daten um."""
    return {
        "version": 1,
        "ort": ort,
        "besuchte_raeume": sorted(besuchte_raeume),
        "spieler": {
            "name": spieler.name,
            "hp": spieler.hp,
            "max_hp": spieler.max_hp,
            "gold": spieler.gold,
            "inventar": gegenstands_kennungen(spieler.inventar),
        },
        "raeume": {
            raum_id: {
                "gegenstaende": gegenstands_kennungen(
                    raum.gegenstaende
                ),
            }
            for raum_id, raum in raeume.items()
        },
    }

Im Ergebnis kommen keine Spieler-, Raum- oder Gegenstand-Objekte mehr vor.

Auch unser Set besuchte_raeume wird umgewandelt:

sorted(besuchte_raeume)

JSON kennt Sets nicht. sorted(...) liefert uns hier eine Liste und sorgt gleichzeitig für eine stabile Reihenfolge in der Speicherdatei.

Der Eintrag

"version": 1

hilft uns später dabei zu erkennen, welches Format die Datei verwendet. Ändert sich die Struktur des Savegames inkompatibel, können wir eine neue Version vergeben und alte Spielstände gezielt migrieren oder ablehnen.

Gegenstände beim Laden wiederherstellen

Beim Laden läuft die Umwandlung in die andere Richtung.

Aus

["fackel", "schluessel"]

müssen wieder echte Gegenstände entstehen:

def gegenstaende_aus_kennungen(kennungen):
    """Erstellt Gegenstände aus ihren gespeicherten Kennungen."""
    return [
        erstelle_gegenstand(kennung)
        for kennung in kennungen
    ]

Ist eine Kennung unbekannt, passt der Spielstand nicht zu unserer aktuellen Gegenstands-Datenbank.

Im vollständigen Code lösen wir deshalb einen SpielstandError aus, statt mit einem unverständlichen KeyError abzubrechen.

Geladene Daten erst prüfen, dann übernehmen

Beim Laden gibt es noch ein Problem, das leicht zu übersehen ist.

Angenommen, wir verändern zuerst direkt den vorhandenen Spieler:

spieler.name = spieler_daten["name"]
spieler.hp = spieler_daten["hp"]

und stellen erst danach fest, dass ein gespeicherter Raum unbekannt ist.

Die Ladeoperation schlägt dann zwar fehl, Teile des laufenden States wurden aber bereits verändert.

Das ist unangenehm: Das Spiel meldet einen fehlerhaften Spielstand, befindet sich anschließend aber trotzdem halb in dessen State.

Wir gehen deshalb anders vor:

  1. JSON vollständig lesen.
  2. Die wichtigen Strukturen und Kennungen prüfen.
  3. Neue Spieler-, Raum- und Gegenstand-Objekte daraus erzeugen.
  4. Erst wenn das erfolgreich war, den neuen State im Game-Loop übernehmen.

Ein fehlgeschlagener Ladevorgang lässt den bisher laufenden Spielstand dadurch unverändert.

Der Dungeon, Schritt 9

Jetzt erweitern wir den Dungeon aus Teil 8 um Speichern und Laden.

Neu sind:

  • SPEICHERDATEI
  • SpielstandError
  • stabile Gegenstandskennungen
  • Umwandlung zwischen Objekten und JSON-kompatiblen Daten
  • validiertes Laden in einen neuen State
  • die Befehle speichern und laden

Öffne dungeon.py und ersetze den bisherigen Inhalt durch folgenden Code:

# Dungeon – Teil 9: Dateien und Speichern
import json
from pathlib import Path


SPEICHERDATEI = Path("spielstand.json")


class DungeonError(Exception):
    """Basisklasse für erwartbare Fehler im Dungeon."""


class UngueltigerBefehlError(DungeonError):
    """Der eingegebene Befehl ist unbekannt oder unvollständig."""


class UnmoeglicheAktionError(DungeonError):
    """Eine bekannte Aktion ist im aktuellen State nicht möglich."""


class SpielstandError(DungeonError):
    """Der Spielstand kann nicht gespeichert oder geladen werden."""


class Gegenstand:
    """Ein Gegenstand, der in einem Raum oder Inventar liegen kann."""

    def __init__(self, kennung, name, beschreibung=""):
        self.kennung = kennung
        self.name = name
        self.beschreibung = beschreibung

    def __str__(self):
        return self.name

    def untersuche(self):
        if self.beschreibung:
            return self.beschreibung

        return f"An {self.name} fällt Dir nichts Besonderes auf."


class Spieler:
    """Verwaltet den State und das Verhalten des Spielers."""

    def __init__(self, name, max_hp=100, gold=0):
        name = name.strip()

        if not name:
            raise ValueError("Der Name darf nicht leer sein.")

        if max_hp <= 0:
            raise ValueError("max_hp muss größer als 0 sein.")

        if gold < 0:
            raise ValueError("gold darf nicht negativ sein.")

        self.name = name
        self.hp = max_hp
        self.max_hp = max_hp
        self.gold = gold
        self.inventar = []

    def nimm(self, gegenstand):
        self.inventar.append(gegenstand)

    def erleide_schaden(self, menge):
        if menge < 0:
            raise ValueError("Schaden darf nicht negativ sein.")

        self.hp = max(self.hp - menge, 0)

    def heile(self, menge):
        if menge < 0:
            raise ValueError("Heilung darf nicht negativ sein.")

        self.hp = min(self.hp + menge, self.max_hp)

    def ist_besiegt(self):
        return self.hp <= 0

    def zeige_inventar(self):
        if not self.inventar:
            print("Dein Inventar ist leer.")
            return

        print("Inventar:")

        for nummer, gegenstand in enumerate(self.inventar, start=1):
            print(f"  {nummer}) {gegenstand}")

    def __str__(self):
        return (
            f"{self.name} | "
            f"HP: {self.hp}/{self.max_hp} | "
            f"Gold: {self.gold} | "
            f"Inventar: {len(self.inventar)}"
        )


class Raum:
    """Ein Raum mit Beschreibung, Ausgängen und Gegenständen."""

    def __init__(
        self,
        name,
        beschreibung,
        ausgaenge=None,
        gegenstaende=None,
    ):
        self.name = name
        self.beschreibung = beschreibung
        self.ausgaenge = dict(ausgaenge) if ausgaenge is not None else {}
        self.gegenstaende = (
            list(gegenstaende)
            if gegenstaende is not None
            else []
        )

    def beschreibe(self):
        print()
        print(f"Ort: {self.name}")
        print(self.beschreibung)

        print("Ausgänge:")

        for nummer, richtung in enumerate(self.ausgaenge, start=1):
            print(f"  {nummer}) {richtung}")

        if self.gegenstaende:
            print(
                "Hier liegt:",
                ", ".join(str(g) for g in self.gegenstaende),
            )

    def zeige_gegenstaende(self):
        if not self.gegenstaende:
            print("Hier liegt nichts Brauchbares.")
            return

        print(
            "Hier liegt:",
            ", ".join(str(g) for g in self.gegenstaende),
        )

    def nimm_gegenstand(self, name):
        gesuchter_name = name.strip().lower()

        for gegenstand in self.gegenstaende:
            if gegenstand.name.lower() == gesuchter_name:
                self.gegenstaende.remove(gegenstand)
                return gegenstand

        raise UnmoeglicheAktionError(
            f"Hier liegt kein Gegenstand namens {name}."
        )

    def ziel(self, richtung):
        try:
            return self.ausgaenge[richtung]
        except KeyError:
            raise UnmoeglicheAktionError(
                f"Du kannst nicht nach {richtung} gehen."
            ) from None


GEGENSTANDS_DATEN = {
    "fackel": {
        "name": "Fackel",
        "beschreibung": "Eine rußige, aber noch brauchbare Fackel.",
    },
    "schluessel": {
        "name": "Schlüssel",
        "beschreibung": "Ein schwerer Eisenschlüssel mit rostigen Zähnen.",
    },
}


def erstelle_gegenstand(kennung):
    """Erstellt einen Gegenstand anhand seiner stabilen Kennung."""
    if not isinstance(kennung, str):
        raise SpielstandError(
            "Eine Gegenstandskennung hat ein unerwartetes Format."
        )

    try:
        daten = GEGENSTANDS_DATEN[kennung]
    except KeyError:
        raise SpielstandError(
            f"Unbekannter Gegenstand im Spielstand: {kennung}"
        ) from None

    return Gegenstand(
        kennung=kennung,
        name=daten["name"],
        beschreibung=daten["beschreibung"],
    )


def erstelle_raeume():
    """Erstellt die Dungeon-Welt."""
    return {
        "halle": Raum(
            name="Halle",
            beschreibung=(
                "Du stehst in einer dunklen Halle mit moderigem Geruch."
            ),
            ausgaenge={
                "norden": "bibliothek",
                "osten": "krypta",
            },
            gegenstaende=[erstelle_gegenstand("fackel")],
        ),
        "bibliothek": Raum(
            name="Bibliothek",
            beschreibung=(
                "Staubige Regale voller zerfledderter Bücher umgeben Dich."
            ),
            ausgaenge={
                "sueden": "halle",
            },
            gegenstaende=[erstelle_gegenstand("schluessel")],
        ),
        "krypta": Raum(
            name="Krypta",
            beschreibung=(
                "Feuchtigkeit glänzt auf den Wänden der alten Krypta."
            ),
            ausgaenge={
                "westen": "halle",
            },
        ),
    }


def frage_ganzzahl(text, minimum=None):
    """Fragt so lange nach einer Ganzzahl, bis die Eingabe gültig ist."""
    while True:
        eingabe = input(text)

        try:
            wert = int(eingabe)
        except ValueError:
            print("Bitte gib eine ganze Zahl ein.")
            continue

        if minimum is not None and wert < minimum:
            print(f"Der Wert muss mindestens {minimum} betragen.")
            continue

        return wert


def frage_name(text):
    """Fragt so lange nach, bis ein nicht leerer Name eingegeben wurde."""
    while True:
        name = input(text).strip()

        if name:
            return name

        print("Der Name darf nicht leer sein.")


def teile_befehl(eingabe):
    """Zerlegt den Input in ein Verb und ein optionales Argument."""
    verb, _, argument = eingabe.strip().lower().partition(" ")

    if not verb:
        raise UngueltigerBefehlError(
            "Bitte gib einen Befehl ein."
        )

    return verb, argument.strip()


def finde_raeume_mit_loot(raeume):
    """Gibt die Namen aller Räume mit Gegenständen zurück."""
    return [
        raum.name
        for raum in raeume.values()
        if raum.gegenstaende
    ]


def gegenstands_kennungen(gegenstaende):
    """Gibt die stabilen Kennungen mehrerer Gegenstände zurück."""
    return [
        gegenstand.kennung
        for gegenstand in gegenstaende
    ]


def gegenstaende_aus_kennungen(kennungen):
    """Erstellt Gegenstände aus ihren gespeicherten Kennungen."""
    if not isinstance(kennungen, list):
        raise SpielstandError(
            "Eine Gegenstandsliste hat ein unerwartetes Format."
        )

    return [
        erstelle_gegenstand(kennung)
        for kennung in kennungen
    ]


def erstelle_spielstand(spieler, ort, raeume, besuchte_raeume):
    """Wandelt den aktuellen State in JSON-kompatible Daten um."""
    return {
        "version": 1,
        "ort": ort,
        "besuchte_raeume": sorted(besuchte_raeume),
        "spieler": {
            "name": spieler.name,
            "hp": spieler.hp,
            "max_hp": spieler.max_hp,
            "gold": spieler.gold,
            "inventar": gegenstands_kennungen(spieler.inventar),
        },
        "raeume": {
            raum_id: {
                "gegenstaende": gegenstands_kennungen(
                    raum.gegenstaende
                ),
            }
            for raum_id, raum in raeume.items()
        },
    }


def rekonstruiere_spielstand(daten):
    """Erzeugt aus geladenen Daten einen neuen Dungeon-State."""
    if not isinstance(daten, dict):
        raise SpielstandError(
            "Der Spielstand hat ein unerwartetes Format."
        )

    if daten.get("version") != 1:
        raise SpielstandError(
            "Der Spielstand hat eine unbekannte Version."
        )

    try:
        spieler_daten = daten["spieler"]
        raeume_daten = daten["raeume"]
        ort = daten["ort"]
        besuchte_daten = daten["besuchte_raeume"]

        name = spieler_daten["name"]
        hp = spieler_daten["hp"]
        max_hp = spieler_daten["max_hp"]
        gold = spieler_daten["gold"]
        inventar_kennungen = spieler_daten["inventar"]
    except (KeyError, TypeError) as fehler:
        raise SpielstandError(
            "Der Spielstand hat ein unerwartetes Format."
        ) from fehler

    if not isinstance(name, str):
        raise SpielstandError(
            "Der gespeicherte Spielername ist ungültig."
        )

    if not isinstance(max_hp, int) or max_hp <= 0:
        raise SpielstandError(
            "Die gespeicherten maximalen Lebenspunkte sind ungültig."
        )

    if not isinstance(hp, int) or not 0 <= hp <= max_hp:
        raise SpielstandError(
            "Die gespeicherten Lebenspunkte sind ungültig."
        )

    if not isinstance(gold, int) or gold < 0:
        raise SpielstandError(
            "Der gespeicherte Goldwert ist ungültig."
        )

    raeume = erstelle_raeume()

    if (
        not isinstance(raeume_daten, dict)
        or set(raeume_daten) != set(raeume)
    ):
        raise SpielstandError(
            "Der Spielstand passt nicht zur aktuellen Dungeon-Welt."
        )

    if not isinstance(ort, str) or ort not in raeume:
        raise SpielstandError(
            f"Unbekannter aktueller Raum im Spielstand: {ort!r}"
        )

    if not isinstance(besuchte_daten, list):
        raise SpielstandError(
            "Die Liste der besuchten Räume hat ein unerwartetes Format."
        )

    try:
        besuchte_raeume = set(besuchte_daten)
    except TypeError as fehler:
        raise SpielstandError(
            "Die Liste der besuchten Räume hat ein unerwartetes Format."
        ) from fehler

    if not besuchte_raeume <= set(raeume):
        raise SpielstandError(
            "Der Spielstand enthält unbekannte besuchte Räume."
        )

    try:
        spieler = Spieler(
            name=name,
            max_hp=max_hp,
            gold=gold,
        )
    except ValueError as fehler:
        raise SpielstandError(
            "Die Spielerdaten sind ungültig."
        ) from fehler

    spieler.hp = hp
    spieler.inventar = gegenstaende_aus_kennungen(
        inventar_kennungen
    )

    try:
        for raum_id, raum in raeume.items():
            raum.gegenstaende = gegenstaende_aus_kennungen(
                raeume_daten[raum_id]["gegenstaende"]
            )
    except (KeyError, TypeError) as fehler:
        raise SpielstandError(
            "Die Raumdaten haben ein unerwartetes Format."
        ) from fehler

    return spieler, ort, raeume, besuchte_raeume


def speichere_spielstand(spieler, ort, raeume, besuchte_raeume):
    """Schreibt den aktuellen Spielstand als JSON-Datei."""
    daten = erstelle_spielstand(
        spieler=spieler,
        ort=ort,
        raeume=raeume,
        besuchte_raeume=besuchte_raeume,
    )

    try:
        with SPEICHERDATEI.open("w", encoding="utf-8") as datei:
            json.dump(
                daten,
                datei,
                ensure_ascii=False,
                indent=2,
            )
            datei.write("\n")
    except OSError as fehler:
        raise SpielstandError(
            f"Der Spielstand konnte nicht gespeichert werden: {fehler}"
        ) from fehler


def lade_spielstand():
    """Lädt und rekonstruiert einen gespeicherten Dungeon-State."""
    try:
        with SPEICHERDATEI.open(encoding="utf-8") as datei:
            daten = json.load(datei)
    except FileNotFoundError:
        raise SpielstandError(
            "Kein Spielstand gefunden."
        ) from None
    except UnicodeDecodeError as fehler:
        raise SpielstandError(
            "Der Spielstand ist keine gültige UTF-8-Datei."
        ) from fehler
    except json.JSONDecodeError as fehler:
        raise SpielstandError(
            "Der Spielstand enthält kein gültiges JSON."
        ) from fehler
    except OSError as fehler:
        raise SpielstandError(
            f"Der Spielstand konnte nicht gelesen werden: {fehler}"
        ) from fehler

    return rekonstruiere_spielstand(daten)


def verarbeite_befehl(
    verb,
    argument,
    spieler,
    raum,
    raeume,
    besuchte_raeume,
):
    """
    Verarbeitet einen Befehl.

    Gibt die ID eines neuen Raums oder None zurück.
    """
    if verb == "umsehen":
        raum.zeige_gegenstaende()
        return None

    if verb == "nimm":
        if not argument:
            raise UngueltigerBefehlError(
                "Was möchtest Du nehmen?"
            )

        gegenstand = raum.nimm_gegenstand(argument)
        spieler.nimm(gegenstand)

        print(f"Du nimmst {gegenstand}.")
        print(gegenstand.untersuche())

        return None

    if verb == "inventar":
        spieler.zeige_inventar()
        return None

    if verb == "status":
        print(spieler)
        print(f"Besuchte Räume: {len(besuchte_raeume)}")
        return None

    if verb == "loot":
        raeume_mit_loot = finde_raeume_mit_loot(raeume)
        text = (
            ", ".join(raeume_mit_loot)
            if raeume_mit_loot
            else "(keine)"
        )

        print("Räume mit Loot:", text)
        return None

    if verb == "geh":
        if not argument:
            raise UngueltigerBefehlError(
                "In welche Richtung möchtest Du gehen?"
            )

        return raum.ziel(argument)

    if not argument:
        try:
            return raum.ausgaenge[verb]
        except KeyError:
            pass

    raise UngueltigerBefehlError(
        f"Den Befehl '{verb}' verstehe ich nicht."
    )


def main():
    """Startet den Dungeon und führt den Game-Loop aus."""
    raeume = erstelle_raeume()

    print("Du stehst vor dem rostigen Tor eines vergessenen Verlieses.")
    print("Ein kalter Luftzug weht Dir entgegen.")

    name = frage_name("Wie heißt Du, Abenteurer? ")
    gold = frage_ganzzahl(
        "Wie viele Goldmünzen bringst Du mit? ",
        minimum=0,
    )

    spieler = Spieler(
        name=name,
        max_hp=100,
        gold=gold,
    )

    ort = "halle"
    besuchte_raeume = set()
    raum_anzeigen = True

    print()
    print(f"Willkommen, {spieler.name}!")
    print(
        "Befehle: umsehen, nimm <Gegenstand>, inventar, status, "
        "loot, speichern, laden, geh <Richtung>, ende"
    )

    while True:
        raum = raeume[ort]

        if raum_anzeigen:
            besuchte_raeume.add(ort)
            raum.beschreibe()
            raum_anzeigen = False

        eingabe = input("\n> ")

        try:
            verb, argument = teile_befehl(eingabe)

            if verb == "ende":
                print("Du verlässt das Verlies. Bis zum nächsten Mal.")
                break

            if verb == "speichern":
                speichere_spielstand(
                    spieler=spieler,
                    ort=ort,
                    raeume=raeume,
                    besuchte_raeume=besuchte_raeume,
                )
                print("Spielstand gespeichert.")
                continue

            if verb == "laden":
                (
                    spieler,
                    ort,
                    raeume,
                    besuchte_raeume,
                ) = lade_spielstand()

                raum_anzeigen = True
                print("Spielstand geladen.")
                continue

            neuer_ort = verarbeite_befehl(
                verb=verb,
                argument=argument,
                spieler=spieler,
                raum=raum,
                raeume=raeume,
                besuchte_raeume=besuchte_raeume,
            )
        except DungeonError as fehler:
            print(fehler)
        else:
            if neuer_ort is not None:
                ort = neuer_ort
                raum_anzeigen = True


if __name__ == "__main__":
    main()

Führe das Programm aus:

python dungeon.py

Ein möglicher Durchlauf:

Du stehst vor dem rostigen Tor eines vergessenen Verlieses.
Ein kalter Luftzug weht Dir entgegen.
Wie heißt Du, Abenteurer? Karl
Wie viele Goldmünzen bringst Du mit? 50

Willkommen, Karl!
Befehle: umsehen, nimm <Gegenstand>, inventar, status, loot, speichern, laden, geh <Richtung>, ende

Ort: Halle
Du stehst in einer dunklen Halle mit moderigem Geruch.
Ausgänge:
  1) norden
  2) osten
Hier liegt: Fackel

> nimm fackel
Du nimmst Fackel.
Eine rußige, aber noch brauchbare Fackel.

> speichern
Spielstand gespeichert.

> norden

Ort: Bibliothek
Staubige Regale voller zerfledderter Bücher umgeben Dich.
Ausgänge:
  1) sueden
Hier liegt: Schlüssel

> laden
Spielstand geladen.

Ort: Halle
Du stehst in einer dunklen Halle mit moderigem Geruch.
Ausgänge:
  1) norden
  2) osten

> inventar
Inventar:
  1) Fackel

> ende
Du verlässt das Verlies. Bis zum nächsten Mal.

Nach dem Speichern findest Du im Projektordner die Datei spielstand.json.

Sie sieht ungefähr so aus:

{
  "version": 1,
  "ort": "halle",
  "besuchte_raeume": [
    "halle"
  ],
  "spieler": {
    "name": "Karl",
    "hp": 100,
    "max_hp": 100,
    "gold": 50,
    "inventar": [
      "fackel"
    ]
  },
  "raeume": {
    "halle": {
      "gegenstaende": []
    },
    "bibliothek": {
      "gegenstaende": [
        "schluessel"
      ]
    },
    "krypta": {
      "gegenstaende": []
    }
  }
}

Entscheidend sind dabei sowohl das Inventar als auch der Zustand der Welt:

  • Im Inventar liegt "fackel".
  • In der Halle liegt keine Fackel mehr.
  • In der Bibliothek liegt weiterhin "schluessel".

Dadurch lässt sich derselbe Spielzustand später rekonstruieren.

Speichern im Game-Loop

Der Befehl speichern ruft unsere Speicherfunktion auf:

if verb == "speichern":
    speichere_spielstand(
        spieler=spieler,
        ort=ort,
        raeume=raeume,
        besuchte_raeume=besuchte_raeume,
    )
    print("Spielstand gespeichert.")
    continue

Die Funktion erhält alles, was den aktuellen Spielstand ausmacht:

  • Spieler
  • aktuelle Raum-ID
  • Dungeon-Welt
  • bereits besuchte Räume

erstelle_spielstand(...) übersetzt diesen State zunächst in einfache Python-Daten. Erst danach übernimmt json.dump(...) die Serialisierung.

Damit bleibt die Frage

Wie sieht unser Spielstand aus?

von der Frage

Wie schreiben wir JSON in eine Datei?

getrennt.

Laden im Game-Loop

Beim Laden machen wir es diesmal bewusst anders als bei vielen einfachen Beispielen.

Wir verändern nicht sofort die vorhandenen Objekte.

(
    spieler,
    ort,
    raeume,
    besuchte_raeume,
) = lade_spielstand()

lade_spielstand() liest zunächst die Datei. Anschließend baut rekonstruiere_spielstand(...) daraus einen vollständigen neuen State.

Erst wenn alle dafür notwendigen Schritte erfolgreich waren, erfolgt die Zuweisung an die vier Namen im Game-Loop.

Schlägt das Laden vorher fehl, bleibt der bisherige State erhalten.

Nach einem erfolgreichen Ladevorgang setzen wir:

raum_anzeigen = True

Dadurch wird der geladene Raum im nächsten Schleifendurchlauf wieder beschrieben.

Fehler beim Laden behandeln

Beim Laden kann einiges schiefgehen:

  • Die Datei existiert nicht.
  • Sie lässt sich nicht lesen.
  • Sie ist nicht als UTF-8 lesbar.
  • Der Inhalt ist kein gültiges JSON.
  • Die Version ist unbekannt.
  • Wichtige Felder fehlen oder haben einen falschen Typ.
  • Ein gespeicherter Raum existiert nicht.
  • Eine Gegenstandskennung ist unbekannt.

Für solche erwartbaren Probleme verwenden wir:

class SpielstandError(DungeonError):
    """Der Spielstand kann nicht gespeichert oder geladen werden."""

Da SpielstandError von DungeonError erbt, erreicht er unseren bereits vorhandenen Handler im Game-Loop:

except DungeonError as fehler:
    print(fehler)

Ein fehlender Spielstand führt deshalb beispielsweise zu:

Kein Spielstand gefunden.

Echte Programmierfehler fangen wir weiterhin nicht pauschal ab.

Warum wir vor dem Öffnen nicht .exists() prüfen

Path.exists() ist durchaus nützlich:

if pfad.exists():
    print("Die Datei existiert.")

Für das eigentliche Laden brauchen wir die Vorabprüfung aber nicht.

Statt:

if not SPEICHERDATEI.exists():
    raise SpielstandError("Kein Spielstand gefunden.")

with SPEICHERDATEI.open(encoding="utf-8") as datei:
    ...

versuchen wir direkt, die Datei zu öffnen:

try:
    with SPEICHERDATEI.open(encoding="utf-8") as datei:
        daten = json.load(datei)
except FileNotFoundError:
    raise SpielstandError(
        "Kein Spielstand gefunden."
    ) from None

Das passt zu EAFP aus Teil 8: Wir führen die gewünschte Operation aus und behandeln den konkreten Fehler.

Eine vorherige .exists()-Prüfung könnte ohnehin keine Garantie geben. Zwischen Prüfung und open(...) könnte die Datei gelöscht oder ersetzt werden.

Außerdem kann eine Datei existieren und trotzdem nicht lesbar sein.

Die Reihenfolge der except-Blöcke zählt

FileNotFoundError ist eine Unterklasse von OSError.

Deshalb steht dieser Handler:

except FileNotFoundError:
    ...

vor:

except OSError:
    ...

Würden wir OSError zuerst behandeln, würde eine fehlende Datei bereits dort landen und der speziellere Handler wäre nicht mehr erreichbar.

Das ist dasselbe Prinzip wie bei unseren if-Bedingungen aus Teil 3: Spezifischere Fälle müssen vor allgemeineren stehen, wenn der allgemeinere Fall sie ebenfalls erfassen würde.

Gültiges JSON bedeutet nicht gültigen Spielstand

json.load(...) beantwortet zunächst nur die Frage:

Ist das ein JSON-Dokument, das Python lesen kann?

Eine Datei wie diese ist vollkommen gültiges JSON:

{
  "version": 1,
  "spieler": "Kartoffel"
}

Für unseren Dungeon ist sie trotzdem kein gültiger Spielstand.

Darum prüft rekonstruiere_spielstand(...) anschließend die Struktur und einige wichtige Werte.

Beispielsweise muss der aktuelle Raum tatsächlich existieren:

if not isinstance(ort, str) or ort not in raeume:
    raise SpielstandError(
        f"Unbekannter aktueller Raum im Spielstand: {ort!r}"
    )

Auch die HP müssen zu den maximalen HP passen:

if not isinstance(hp, int) or not 0 <= hp <= max_hp:
    raise SpielstandError(
        "Die gespeicherten Lebenspunkte sind ungültig."
    )

Und für unsere Version 1 erwarten wir genau die Räume der aktuellen Dungeon-Welt:

if (
    not isinstance(raeume_daten, dict)
    or set(raeume_daten) != set(raeume)
):
    raise SpielstandError(
        "Der Spielstand passt nicht zur aktuellen Dungeon-Welt."
    )

Ein echtes Spiel würde sein Savegame-Schema wahrscheinlich noch gründlicher validieren und alte Versionen migrieren. Für unseren Dungeon reicht dieser Stand, um die wichtigsten Probleme nicht ungeprüft in das laufende Spiel zu übernehmen.

Warum wir einen neuen State aufbauen

Die Funktion

rekonstruiere_spielstand(daten)

erzeugt zunächst eine neue Dungeon-Welt:

raeume = erstelle_raeume()

Dann wird ein neuer Spieler erzeugt und mit den gespeicherten Daten befüllt.

Erst am Ende gibt die Funktion alles gemeinsam zurück:

return spieler, ort, raeume, besuchte_raeume

Das ist für das Fehlerverhalten wichtig.

Wäre beispielsweise eine Gegenstandskennung beschädigt, löst das Laden einen SpielstandError aus. Die neuen, noch lokalen Objekte werden anschließend einfach nicht verwendet.

Der bereits laufende Spieler wurde bis dahin nicht verändert.

Man kann dieses Prinzip allgemein gut gebrauchen:

Daten erst vollständig prüfen und vorbereiten, dann den bestehenden State ersetzen.

Direkt schreiben oder sicherer ersetzen?

Unsere Speicherfunktion öffnet spielstand.json direkt mit "w":

with SPEICHERDATEI.open("w", encoding="utf-8") as datei:
    json.dump(daten, datei, ensure_ascii=False, indent=2)

Für das Tutorial ist das ausreichend.

Der Nachteil ist bekannt: "w" leert eine bestehende Datei beim Öffnen. Wird der Schreibvorgang anschließend unterbrochen, kann der einzige vorhandene Spielstand unvollständig sein.

Robustere Programme schreiben deshalb häufig zunächst in eine temporäre Datei:

temp_datei = SPEICHERDATEI.with_suffix(".tmp")

with temp_datei.open("w", encoding="utf-8") as datei:
    json.dump(
        daten,
        datei,
        ensure_ascii=False,
        indent=2,
    )
    datei.write("\n")

temp_datei.replace(SPEICHERDATEI)

Die vorhandene Speicherdatei bleibt dabei bis zum abschließenden Ersetzen unangetastet.

Das reduziert das Risiko einer halb geschriebenen Zieldatei deutlich. Je nach Anforderungen gehen robuste Speichersysteme noch weiter, etwa mit Backups, Synchronisation der Daten auf das Speichermedium oder mehreren Spielstandgenerationen.

Für unseren Dungeon wäre das an dieser Stelle mehr Infrastruktur als Lerngewinn, deshalb bleibt der vollständige Beispielcode bei der direkten Variante.

Noch ein Detail zu close()

Dass eine Datei korrekt geschlossen wurde, bedeutet nicht automatisch, dass ihre Daten jeden denkbaren Systemausfall überstehen würden.

close() sorgt dafür, dass Python seine Puffer leert und die Dateiressource freigibt. Betriebssystem und Hardware können Daten anschließend selbst noch cachen.

Für normale Anwendungen ist das völlig ausreichend. Programme mit besonders hohen Anforderungen an Durability, beispielsweise Datenbanken, müssen noch weitere Schritte berücksichtigen.

Für unser Tutorial reicht die wichtigere Regel:

Geöffnete Dateien sauber schließen – am besten automatisch mit with.

Stolperfallen

  • with weglassen: Ohne Context Manager musst Du selbst sicherstellen, dass die Datei auch bei Exceptions geschlossen wird.

  • Mit "w" versehentlich Daten löschen: Eine bestehende Datei wird beim Öffnen in diesem Modus geleert.

  • Das Encoding vergessen: Ohne encoding="utf-8" hängt das verwendete Encoding von der Python- und Systemkonfiguration ab.

  • json.dump(...) und json.dumps(...) verwechseln: dump schreibt an ein File-like Object, dumps erzeugt einen String.

  • json.load(...) und json.loads(...) verwechseln: load liest aus einem File-like Object, loads aus einem String.

  • Eigene Objekte direkt speichern wollen: Das Standardverhalten des json-Moduls kennt unsere Klassen Spieler, Raum und Gegenstand nicht.

  • Python-Typen nach einem JSON-Roundtrip unverändert erwarten: Ein Tupel wird beispielsweise als JSON-Array gespeichert und beim Laden zu einer Liste.

  • Sets direkt serialisieren: Ein set gehört nicht zu den standardmäßig unterstützten JSON-Typen. Wir wandeln besuchte_raeume deshalb in eine Liste um.

  • Nur den Player State speichern: Dann können Gegenstände nach dem Laden wieder zusätzlich an ihren ursprünglichen Orten auftauchen.

  • Sichtbare Namen als dauerhafte Kennung verwenden: Anzeigenamen dürfen sich ändern. Eine interne Kennung ist für gespeicherte Referenzen stabiler.

  • Eine Kennung mit einer Instanz-ID verwechseln: Unsere Kennung "fackel" reicht für diesen Dungeon. Für mehrere individuell veränderliche Fackeln bräuchten wir später zusätzliche Daten oder eigene IDs pro Instanz.

  • Gültiges JSON mit gültigen Daten verwechseln: Der JSON-Parser prüft die Syntax des Formats, nicht die fachlichen Regeln Deines Programms.

  • Geladene Werte sofort in den laufenden State schreiben: Scheitert die Validierung später, kann ein halb veränderter State zurückbleiben.

  • .exists() als Garantie betrachten: Zwischen Prüfung und Nutzung kann sich das Dateisystem ändern. Behandle Fehler der eigentlichen Dateioperation trotzdem.

  • Zu breite Exceptions beim Laden fangen: Ein except Exception würde auch Bugs im Ladecode als vermeintlich defekten Spielstand erscheinen lassen.

  • Bei Dictionary-Schlüsseln nicht an JSON denken: JSON-Objekte haben String-Schlüssel. Für dauerhafte Dateiformate sind klare String-IDs deshalb besonders angenehm.

Übungen

1. Eine Notiz anhängen

Schreibe eine Funktion notiere(text), die eine Zeile an die Datei logbuch.txt anhängt.

Lösung
def notiere(text):
    with open("logbuch.txt", "a", encoding="utf-8") as datei:
        datei.write(text + "\n")
Der Modus `"a"` hängt neue Daten an das Ende an, statt die bestehende Datei zu leeren.

2. Text mit pathlib schreiben und lesen

Schreibe mit Path.write_text(...) den Text "Hallo\n" in notiz.txt und lies ihn anschließend mit Path.read_text(...) wieder ein.

Lösung
from pathlib import Path

pfad = Path("notiz.txt")

pfad.write_text("Hallo\n", encoding="utf-8")

text = pfad.read_text(encoding="utf-8")

print(text)

3. Spielzüge speichern

Erweitere den Spielstand um zuege, die Anzahl der eingegebenen Befehle.

Lösung Beim Start:
zuege = 0
Nach jeder Eingabe:
zuege += 1
Beim Erzeugen des Spielstands ergänzt Du:
"zuege": zuege,
Beim Rekonstruieren liest Du den Wert wieder aus und gibst ihn zusammen mit dem restlichen State zurück. Da wir inzwischen bewusst einen vollständigen neuen State erzeugen, wäre der Return Value beispielsweise:
return spieler, ort, raeume, besuchte_raeume, zuege
Der Aufruf im Game-Loop müsste entsprechend angepasst werden.

4. Ungültiges JSON behandeln

Schreibe eine Funktion lade_json(pfad), die eine JSON-Datei lädt. Enthält sie ungültiges JSON, soll eine verständliche Meldung ausgegeben und None zurückgegeben werden.

Lösung
import json
from pathlib import Path


def lade_json(pfad):
    try:
        with Path(pfad).open(encoding="utf-8") as datei:
            return json.load(datei)
    except json.JSONDecodeError:
        print("Die Datei enthält kein gültiges JSON.")
        return None
Für ein vollständigeres Programm müsstest Du zusätzlich Fehler beim Öffnen und Lesen der Datei behandeln.

Im nächsten Teil

Unser Dungeon kann seinen Spielstand jetzt speichern und wieder laden. Die Datei dungeon.py wird dadurch allerdings immer länger: Klassen, Eingabe, Spielwelt, Befehlsverarbeitung und Speicherlogik stehen weiterhin an einer Stelle.

In Teil 10 zerlegen wir das Projekt deshalb in mehrere Module. Dabei vertiefen wir import und den bereits bekannten __main__-Guard und beschäftigen uns mit einer sinnvollen Projektstruktur, venv und pip.

Weiterlesen

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!