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 alles weg.

Der aktuelle Raum, das Inventar, die Lebenspunkte und die eingesammelten Gegenstände existieren nur im Arbeitsspeicher. Beim nächsten Start beginnt das Spiel wieder von vorn.

Heute geben wir dem Dungeon ein Gedächtnis. Wir speichern den Spielstand in einer Datei und laden ihn später wieder ein.

Dabei lernen wir:

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

Dateien schreiben

Eine Datei öffnest Du mit open(...).

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

Der erste Wert ist der Pfad zur Datei. Der zweite Wert ist der Modus.

Der Modus "w" bedeutet: schreiben.

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

Nach dem Schreiben muss die Datei wieder geschlossen werden. Erst dann ist zuverlässig klar, dass alle Daten fertig geschrieben und die Ressourcen des Betriebssystems freigegeben wurden.

So solltest Du es im Alltag aber nicht schreiben. Wenn zwischen open(...) und close() ein Fehler auftritt, wird close() möglicherweise nie erreicht.

Deshalb verwenden wir einen with-Block.

Dateien mit with öffnen

Die typische Schreibweise lautet:

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

Der Block öffnet die Datei, stellt sie unter dem Namen datei bereit und schließt sie automatisch wieder, sobald der eingerückte Block verlassen wird.

Das gilt auch, wenn innerhalb des Blocks eine Exception auftritt.

with verwendet einen Kontextmanager (Context Manager). Bei Dateien bedeutet das:

  1. Datei öffnen.
  2. Mit der Datei arbeiten.
  3. Datei zuverlässig schließen.

Ohne with müsstest Du denselben Effekt mit try und finally selbst bauen:

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

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

Das funktioniert, ist aber umständlicher und fehleranfälliger.

Für Dateien solltest Du deshalb fast immer with verwenden.

Wichtige Datei-Modi

Der Modus bestimmt, was beim Öffnen mit der Datei passiert.

Modus Bedeutung
"r" lesen, Datei muss existieren
"w" schreiben, Datei wird neu angelegt oder überschrieben
"a" anhängen, neue Daten werden ans Ende geschrieben
"x" exklusiv neu anlegen, Fehler falls Datei existiert
"r+" lesen und schreiben, Datei muss existieren

Der gefährlichste Modus für Anfänger ist "w".

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

Wenn die Datei bereits existiert, wird ihr Inhalt beim Öffnen gelöscht. Das passiert, bevor Du etwas Neues hineinschreibst.

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

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

Dadurch wird die neue Zeile ans Ende der Datei angehängt.

Immer ein Encoding angeben

Beim Arbeiten mit Textdateien solltest Du das Encoding ausdrücklich angeben:

encoding="utf-8"

Also:

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

Ohne Encoding verwendet Python ein plattformabhängiges Standard-Encoding. Das kann auf verschiedenen Systemen unterschiedlich sein.

Gerade bei Umlauten wie ä, ö, ü oder ß führt das schnell zu Problemen.

Für unsere Beispiele verwenden wir deshalb konsequent UTF-8.

Dateien lesen

Eine ganze Datei kannst Du mit .read() lesen:

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

print(inhalt)

Der Modus "r" ist der Standard. Du kannst ihn deshalb auch weglassen:

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

.read() lädt die gesamte Datei als einen String in den Speicher.

Für kleine Konfigurationsdateien, Notizen oder Spielstände ist das völlig in Ordnung.

Dateien zeilenweise lesen

Bei großen Dateien ist es oft besser, zeilenweise zu lesen:

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

Eine Datei ist iterierbar. Die for-Schleife liefert nacheinander jede Zeile.

Dabei enthält zeile normalerweise den Zeilenumbruch am Ende. Möchtest Du ihn entfernen, verwendest Du .rstrip():

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

pathlib: Pfade als Objekte

Bisher haben wir Pfade als Strings geschrieben:

"spielstand.json"

Für einfache Fälle funktioniert das. Sobald Pfade aber zusammengesetzt, geprüft oder verändert werden, ist pathlib angenehmer.

from pathlib import Path

Ein Path-Objekt beschreibt einen Dateisystempfad:

speicherdatei = Path("spielstand.json")

Du kannst prüfen, ob die Datei existiert:

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

Oder den Namen der Datei ausgeben:

print(speicherdatei.name)

Ausgabe:

spielstand.json

Pfade zusammensetzen

Pfade werden mit / zusammengesetzt:

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

Das Ergebnis ist wieder ein Path:

Path("daten/spielstand.json")

pathlib kümmert sich dabei um die passenden Pfadtrenner des jeweiligen Betriebssystems.

Unter Windows werden Pfade anders dargestellt als unter Linux oder macOS. Du musst deshalb keine Strings mit "/" oder "\\" zusammenkleben.

Text direkt mit Path lesen und schreiben

Für kurze Textdateien bietet Path praktische Methoden:

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)

Intern öffnet und schließt pathlib die Datei für Dich.

Für viele einfache Fälle sind read_text(...) und write_text(...) sehr bequem.

Sobald Du aber mehrere Schreibvorgänge, spezielle Datei-Modi oder Streaming brauchst, ist ein expliziter with-Block mit .open(...) klarer:

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

Path.open(...) funktioniert ähnlich wie das eingebaute open(...), arbeitet aber direkt auf dem Path-Objekt.

Warum reiner Text nicht reicht

Eine einfache Notiz ist nur Text:

Du bist in der Halle.

Unser Spielstand besteht aber aus mehreren zusammengehörigen Werten:

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

Natürlich könnten wir uns ein eigenes Textformat ausdenken:

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

Das wird aber schnell fehleranfällig. Wir müssten selbst festlegen, wie Listen, Zahlen, Sonderzeichen und verschachtelte Daten gespeichert werden.

Für solche strukturierten Daten eignet sich JSON.

JSON: strukturierte Daten als Text

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

Ein JSON-Spielstand könnte 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

Python-Werte und JSON-Werte

JSON kann nicht beliebige Python-Objekte speichern, sondern nur einfache Grundstrukturen.

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

Ein einfaches Dictionary lässt sich direkt speichern:

import json

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

Ein eigenes Objekt wie Spieler, Raum oder Gegenstand lässt sich dagegen nicht direkt speichern:

json.dumps(spieler)

Das führt zu einem TypeError, weil das json-Modul nicht weiß, wie ein beliebiges Python-Objekt in JSON übersetzt werden soll.

Deshalb bauen wir vor dem Speichern ein Dictionary aus einfachen Werten.

JSON schreiben

Mit json.dump(...) schreibst Du JSON direkt in eine geöffnete Datei:

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")

Die Option

ensure_ascii=False

sorgt dafür, dass Umlaute lesbar bleiben.

Ohne diese Option würde aus Schlüssel beispielsweise eine Schreibweise mit Escape-Sequenz.

Die Option

indent=2

rückt die JSON-Datei ein. Das ist für Menschen besser lesbar.

Die letzte Zeile

datei.write("\n")

setzt einen Zeilenumbruch ans Dateiende. Das ist nicht zwingend notwendig, aber für Textdateien eine saubere Gewohnheit.

JSON als String erzeugen

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

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

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

print(text)

Das s am Ende steht für String.

Der Unterschied:

Funktion Ergebnis
json.dump(daten, datei) schreibt in eine Datei
json.dumps(daten) erzeugt einen String

Zum Lesen gibt es entsprechend:

Funktion Eingabe
json.load(datei) liest aus einer Datei
json.loads(text) liest aus einem String

JSON lesen

Mit json.load(...) liest Du JSON aus einer geöffneten Datei:

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

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

Wenn die Datei kein gültiges JSON enthält, löst json.load(...) 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.")

Das ist besonders wichtig, wenn ein Benutzer die Datei von Hand verändert oder ein Schreibvorgang abgebrochen wurde.

Spielstände brauchen stabile Kennungen

Im Dungeon gibt es Gegenstände wie:

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

Wenn wir nur den sichtbaren Namen "Fackel" speichern, funktioniert das am Anfang. Es ist aber nicht ideal.

Der sichtbare Name kann sich später ändern:

Fackel
Alte Fackel
Rußige Fackel

Außerdem kann es irgendwann zwei Gegenstände mit ähnlichem Namen geben.

Deshalb speichern wir eine stabile interne Kennung:

"fackel"
"schluessel"

Der Spieler sieht weiterhin "Fackel" und "Schlüssel". Die Speicherdatei merkt sich aber die Kennungen.

Dafür erweitern wir Gegenstand:

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

Ein Gegenstand besitzt nun:

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

Eine Gegenstands-Datenbank im Code

Für unser kleines Spiel reicht ein Dictionary mit den bekannten Gegenständen:

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.",
    },
}

Daraus erzeugen wir bei Bedarf echte Gegenstand-Objekte:

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

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

Später könnte diese Gegenstands-Datenbank selbst aus einer Datei geladen werden. Für diesen Teil bleibt sie fest im Code.

Warum wir auch die Räume speichern müssen

Ein häufiger Fehler beim ersten Speichern eines Spiels ist, nur den Spieler zu speichern:

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

Beim Laden wird die Welt aber neu erzeugt. In der ursprünglichen Welt liegt die Fackel wieder in der Halle.

Das Ergebnis wäre falsch:

  • Die Fackel liegt im Inventar.
  • Die Fackel liegt zusätzlich wieder in der Halle.

Deshalb speichern wir nicht nur den Spieler, sondern auch den veränderten Zustand der Räume.

Für jeden Raum merken wir uns, welche Gegenstände dort noch liegen:

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

So kann das Spiel nach dem Laden genau denselben Zustand wiederherstellen.

Einen Spielstand als Dictionary erzeugen

Der aktuelle State muss vor dem Speichern in JSON-kompatible Daten umgewandelt werden.

Eine Hilfsfunktion macht aus Gegenständen ihre Kennungen:

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

Die Funktion für den gesamten Spielstand:

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()
        },
    }

Das Ergebnis enthält nur noch Werte, die JSON speichern kann:

  • Strings
  • Zahlen
  • Listen
  • Dictionaries

Die "version" hilft später, falls wir das Format des Spielstands ändern.

Gegenstände beim Laden wiederherstellen

Beim Laden müssen aus den gespeicherten Kennungen wieder Objekte entstehen:

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

Aus

["fackel", "schluessel"]

werden wieder echte Gegenstand-Objekte.

Wenn eine Kennung im Spielstand nicht bekannt ist, ist die Datei nicht mehr mit dem aktuellen Spiel kompatibel. In diesem Fall lösen wir später einen SpielstandError aus.

Der Dungeon, Schritt 9

Wir erweitern den Dungeon aus Teil 8 um Speichern und Laden.

Neu sind:

  • SPEICHERDATEI
  • SpielstandError
  • stabile Gegenstandskennungen
  • JSON-Funktionen zum Speichern und Laden
  • 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."""
    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."""
    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 wende_spielstand_an(daten, spieler, raeume):
    """Überträgt geladene Daten auf Spieler und Räume."""
    if daten.get("version") != 1:
        raise SpielstandError(
            "Der Spielstand hat eine unbekannte Version."
        )

    spieler_daten = daten["spieler"]

    spieler.name = spieler_daten["name"]
    spieler.hp = spieler_daten["hp"]
    spieler.max_hp = spieler_daten["max_hp"]
    spieler.gold = spieler_daten["gold"]
    spieler.inventar = gegenstaende_aus_kennungen(
        spieler_daten["inventar"]
    )

    for raum_id, raum_daten in daten["raeume"].items():
        if raum_id not in raeume:
            raise SpielstandError(
                f"Unbekannter Raum im Spielstand: {raum_id}"
            )

        raeume[raum_id].gegenstaende = gegenstaende_aus_kennungen(
            raum_daten["gegenstaende"]
        )

    ort = daten["ort"]

    if ort not in raeume:
        raise SpielstandError(
            f"Unbekannter aktueller Raum im Spielstand: {ort}"
        )

    return ort, set(daten.get("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(spieler, raeume):
    """Lädt den Spielstand und gibt Ort sowie besuchte Räume zurück."""
    if not SPEICHERDATEI.exists():
        raise SpielstandError("Kein Spielstand gefunden.")

    try:
        with SPEICHERDATEI.open(encoding="utf-8") as datei:
            daten = json.load(datei)
    except json.JSONDecodeError as fehler:
        raise SpielstandError(
            "Der Spielstand ist keine gültige JSON-Datei."
        ) from fehler
    except OSError as fehler:
        raise SpielstandError(
            f"Der Spielstand konnte nicht gelesen werden: {fehler}"
        ) from fehler

    try:
        return wende_spielstand_an(
            daten=daten,
            spieler=spieler,
            raeume=raeume,
        )
    except (KeyError, TypeError) as fehler:
        raise SpielstandError(
            "Der Spielstand hat ein unerwartetes Format."
        ) from fehler


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":
                ort, besuchte_raeume = lade_spielstand(
                    spieler=spieler,
                    raeume=raeume,
                )
                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


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.

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

Nach dem Speichern findest Du im Projektordner eine 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": []
    }
  }
}

Wichtig ist der Unterschied zwischen Inventar und Raumzustand:

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

Damit kann das Spiel den Zustand korrekt wiederherstellen.

Speichern im Game-Loop

Der neue Befehl speichern wird direkt im Game-Loop behandelt:

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

Die Funktion speichere_spielstand(...) erhält alles, was für den Spielstand relevant ist:

  • Spielerobjekt
  • aktuelle Raum-ID
  • alle Räume
  • besuchte Räume

Sie wandelt diesen State in ein JSON-kompatibles Dictionary um und schreibt es in die Datei.

Laden im Game-Loop

Der Befehl laden liest die Datei und verändert den aktuellen State:

if verb == "laden":
    ort, besuchte_raeume = lade_spielstand(
        spieler=spieler,
        raeume=raeume,
    )
    raum_anzeigen = True
    print("Spielstand geladen.")
    continue

Die Funktion gibt zwei Werte zurück:

ort, besuchte_raeume

Der Spieler und die Räume werden direkt verändert. Das passt zu unserem bisherigen Modell: spieler und raeume sind mutable Objekte.

Nach dem Laden setzen wir:

raum_anzeigen = True

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

Fehler beim Laden behandeln

Beim Laden kann einiges schiefgehen:

  • Die Datei existiert nicht.
  • Die Datei enthält kein gültiges JSON.
  • Der Spielstand hat eine unbekannte Version.
  • Ein gespeicherter Raum existiert im aktuellen Spiel nicht mehr.
  • Eine gespeicherte Gegenstandskennung ist unbekannt.
  • Die Datei hat nicht die erwartete Struktur.

Deshalb gibt es eine eigene Exception:

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

Da SpielstandError von DungeonError erbt, wird sie im Game-Loop durch den bereits vorhandenen Handler behandelt:

except DungeonError as fehler:
    print(fehler)

Ein fehlender Spielstand führt dadurch nicht zu einem Traceback, sondern zu einer normalen Meldung:

Kein Spielstand gefunden.

Echte Programmierfehler bleiben weiterhin sichtbar, weil wir nicht pauschal alle Exceptions abfangen.

Warum Path.exists() allein nicht reicht

Vor dem Laden prüfen wir:

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

Das ist benutzerfreundlich, weil wir eine klare Meldung ausgeben können.

Trotzdem kann danach beim Öffnen noch ein OSError auftreten:

except OSError as fehler:
    raise SpielstandError(
        f"Der Spielstand konnte nicht gelesen werden: {fehler}"
    ) from fehler

Zwischen der Prüfung und dem Öffnen kann sich das Dateisystem ändern. Außerdem kann eine Datei existieren, aber nicht lesbar sein.

Deshalb ist die Prüfung mit .exists() nur eine freundliche Vorabprüfung, keine vollständige Fehlerbehandlung.

JSON ist nicht automatisch vertrauenswürdig

Eine JSON-Datei kann von Hand verändert, beschädigt oder aus einer alten Programmversion stammen.

Deshalb prüft wende_spielstand_an(...) zumindest die wichtigsten Dinge:

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

Auch unbekannte Räume werden abgefangen:

if raum_id not in raeume:
    raise SpielstandError(
        f"Unbekannter Raum im Spielstand: {raum_id}"
    )

Für ein echtes Spiel würde man noch deutlich mehr validieren. Für diesen Tutorial-Stand reicht es, die wichtigsten Fehler nicht ungeprüft in den State zu übernehmen.

Direkt schreiben oder sicher ersetzen?

Unsere Funktion öffnet die Datei im Modus "w":

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

Das ist für ein Lernbeispiel in Ordnung.

Der Nachteil: Wenn das Programm oder der Rechner mitten im Schreiben abstürzt, kann die Datei leer oder unvollständig sein.

Robustere Programme schreiben häufig zuerst in eine temporäre Datei und ersetzen danach die alte Datei in einem Schritt.

Das Prinzip sieht so aus:

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)

Für unseren Dungeon müssen wir das noch nicht einbauen. Es ist aber gut zu wissen, warum einfaches "w" nicht die robusteste Speicherstrategie ist.

Stolperfallen

  • with weglassen: Ohne with musst Du selbst zuverlässig dafür sorgen, dass die Datei geschlossen wird.

  • Im Modus "w" versehentlich überschreiben: "w" leert eine bestehende Datei sofort beim Öffnen.

  • encoding vergessen: Ohne encoding="utf-8" hängt die Kodierung vom System ab.

  • json.dump(...) und json.dumps(...) verwechseln: dump schreibt in eine Datei, dumps erzeugt einen String.

  • json.load(...) und json.loads(...) verwechseln: load liest aus einer Datei, loads liest aus einem String.

  • Objekte direkt speichern wollen: Spieler, Raum und Gegenstand müssen zuerst in JSON-kompatible Grundtypen umgewandelt werden.

  • Nur das Inventar speichern: Dann liegen aufgenommene Gegenstände nach dem Laden möglicherweise wieder zusätzlich im Raum.

  • Sichtbare Namen statt stabiler Kennungen speichern: Namen können sich ändern. Eine interne Kennung wie "fackel" ist stabiler.

  • JSON-Dateien blind vertrauen: Eine beschädigte oder alte Datei kann unerwartete Struktur besitzen. Behandle Ladefehler gezielt.

  • Path.exists() als vollständige Sicherheit ansehen: Eine Datei kann nach der Prüfung verschwinden oder trotz Existenz nicht lesbar sein.

  • Sets direkt als JSON speichern: json kennt keine Sets. Wandle sie vorher in Listen um, zum Beispiel mit sorted(...).

  • Dictionary-Schlüssel beliebig wählen: JSON-Objekte verwenden Strings als Schlüssel. Für Spielstände sind klare String-IDs sinnvoll.

Übungen

1. Eine Notiz anhängen

Schreibe eine Funktion notiere(text), die eine Zeile ans Ende der 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 Text an, statt die Datei zu überschreiben.

2. Text mit pathlib schreiben und lesen

Schreibe mit Path.write_text(...) den Text "Hallo\n" in notiz.txt und lies ihn danach 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 eine Zahl zuege, die die Anzahl der eingegebenen Befehle speichert.

Lösung Beim Start:
zuege = 0
Im Game-Loop nach einer Eingabe:
zuege += 1
Beim Erstellen des Spielstands:
"zuege": zuege,
Beim Laden kannst Du den Wert zurückgeben:
return ort, set(daten.get("besuchte_raeume", [])), daten.get("zuege", 0)
Der Aufruf im Game-Loop müsste dann entsprechend angepasst werden:
ort, besuchte_raeume, zuege = lade_spielstand(
    spieler=spieler,
    raeume=raeume,
)

4. Ungültiges JSON behandeln

Schreibe eine Funktion lade_json(pfad), die eine JSON-Datei lädt. Wenn die Datei ungültiges JSON enthält, 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 echtes Programm würdest Du zusätzlich `OSError` behandeln, falls die Datei nicht gelesen werden kann.

Im nächsten Teil

Unser Dungeon kann seinen Spielstand jetzt speichern und laden. Die Datei dungeon.py wird dadurch aber immer länger: Klassen, Eingabe, Spielwelt, Befehlsverarbeitung und Speicherlogik stehen alle in einer Datei.

In Teil 10geplant zerlegen wir das Projekt in mehrere Module. Dabei lernen wir import, if __name__ == "__main__", eine einfache Projektstruktur sowie den Einsatz von venv und pip kennen.

Weiterlesen

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!