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
withbeim Arbeiten mit Dateien wichtig ist, - wie
pathlibPfade 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:
- Datei öffnen.
- Mit der Datei arbeiten.
- 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 IDname: sichtbarer Namebeschreibung: 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:
SPEICHERDATEISpielstandError- stabile Gegenstandskennungen
- JSON-Funktionen zum Speichern und Laden
- 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."""
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
-
withweglassen: Ohnewithmusst 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. -
encodingvergessen: Ohneencoding="utf-8"hängt die Kodierung vom System ab. -
json.dump(...)undjson.dumps(...)verwechseln:dumpschreibt in eine Datei,dumpserzeugt einen String. -
json.load(...)undjson.loads(...)verwechseln:loadliest aus einer Datei,loadsliest aus einem String. -
Objekte direkt speichern wollen:
Spieler,RaumundGegenstandmü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:
jsonkennt keine Sets. Wandle sie vorher in Listen um, zum Beispiel mitsorted(...). -
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")
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
zuege += 1
"zuege": zuege,
return ort, set(daten.get("besuchte_raeume", [])), daten.get("zuege", 0)
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
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
- Python lernen, Teil 8: Wenn's schiefgeht
- Das Datenmodell: Dunder-Methoden verstehengeplant
- Python-Dokumentation: Eingabe und Ausgabe
- Python-Dokumentation:
pathlib - Python-Dokumentation:
json - Python-Dokumentation:
open
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.