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
withbeim Arbeiten mit Dateien wichtig ist, - wie
pathlibPfade 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:
- Datei öffnen.
- Mit der Datei arbeiten.
- 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 Kennungname: sichtbarer Namebeschreibung: 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:
- JSON vollständig lesen.
- Die wichtigen Strukturen und Kennungen prüfen.
- Neue
Spieler-,Raum- undGegenstand-Objekte daraus erzeugen. - 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:
SPEICHERDATEISpielstandError- stabile Gegenstandskennungen
- Umwandlung zwischen Objekten und JSON-kompatiblen Daten
- validiertes Laden in einen neuen State
- die Befehle
speichernundladen
Ö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
-
withweglassen: 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(...)undjson.dumps(...)verwechseln:dumpschreibt an ein File-like Object,dumpserzeugt einen String. -
json.load(...)undjson.loads(...)verwechseln:loadliest aus einem File-like Object,loadsaus einem String. -
Eigene Objekte direkt speichern wollen: Das Standardverhalten des
json-Moduls kennt unsere KlassenSpieler,RaumundGegenstandnicht. -
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
setgehört nicht zu den standardmäßig unterstützten JSON-Typen. Wir wandelnbesuchte_raeumedeshalb 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 Exceptionwü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")
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
zuege += 1
"zuege": zuege,
return spieler, ort, raeume, besuchte_raeume, zuege
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
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.
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.