Se rendre au contenu

Schiffe versenken mit Python und Pygame

· 2026-08-13

Voraussetzungen: Python-Grundlagen (Variablen, Schleifen, Listen, einfache Funktionen) Technisch: Python 3.12 und pygame, eingerichtet nach dem Dokument „Python, uv und einen Editor einrichten“ — dort ist auch erklärt, wo man welchen Befehl eintippt Dauer: ein Arbeitstag (ca. 6–7 h netto) Ziel am Abend: ein spielbares, grafisches Schiffe-versenken mit Gegner-KI – und das Verständnis, warum man Objekte benutzt

Fahrplan

Zeit Block Was passiert Ergebnis
vorab Systemeinrichtung uv, Python 3.12, Editor — separates Dokument läuft, bevor der Tag beginnt
09:00–09:20 Meilenstein 0 Projektordner, Fenster geht auf „Es lebt!“
09:20–10:00 Kapitel A + B Typen-Auffrischung, Objekt-Aha Verständnis
10:00–10:45 Meilenstein 1 Klasse Schiff erste eigene Klasse
10:45–11:45 Meilenstein 2 Klasse Spielfeld im Terminal spielbar
11:45–12:30 Mittag
12:30–13:15 Meilenstein 3 Pygame-Fenster + Game Loop Fenster mit Raster
13:15–14:00 Meilenstein 4 Koordinaten-Mathematik Klick → Feld
14:00–15:00 Meilenstein 5 Schüsse, Treffer, Grafik grafisch spielbar
15:00–15:45 Meilenstein 6 Schiffe platzieren vollständige Runde
15:45–16:45 Meilenstein 7 Gegner-KI in 3 Stufen der Wow-Moment
16:45–17:00 Meilenstein 8 Politur, Demo Vorführung

Hinweis zur Kursleitung: Nach jedem Meilenstein steht ein Code-Review-Checkpoint (unten jeweils markiert). Nicht selbst tippen, nicht die Lösung verraten – nur Fragen stellen. Die Fragen stehen dabei.

Die drei Hausregeln, die den ganzen Tag an der Wand kleben:

  1. Keine globalen Variablen. Zustand lebt in Objekten.
  2. Die Spiellogik kennt Pygame nicht. Kein pygame. in schiff.py oder spielfeld.py.
  3. Eine Methode macht eine Sache. Passt sie nicht auf den Bildschirm, ist sie zu lang.

Regel 2 ist die wichtigste – sie ist das direkte Gegengift gegen Spaghetticode und wird in Meilenstein 2 belohnt: Das Spiel ist im Terminal fertig spielbar, bevor die erste Grafik existiert.

So liest du die Code-Blöcke

Über jedem Block steht, wo er hingehört. Das ist keine Deko — die drei Orte sehen ähnlich aus und verhalten sich völlig verschieden:

Markierung Ort Prompt Wofür
🖥️ Terminal PowerShell / Terminal PS C:\…> bzw. … % starten, installieren
🐍 Python-Konsole gestartet mit uv run python >>> ausprobieren, testen
📄 Datei Editor, dann uv run datei.py keiner das eigentliche Programm

Steht bei einem Block >>> davor, ist das Zeichen schon da — du tippst es nicht mit.

Meilenstein 0 — Setup (20 min)

Ziel: Ein schwarzes Fenster geht auf und lässt sich schließen.

📘 Voraussetzung: Die Einrichtung aus dem Dokument „Python, uv und einen Editor einrichten“ ist einmal durchgelaufen — uv installiert, Python 3.12 vorhanden, Editor eingerichtet. Falls nicht: erst dort das Kapitel für deinen Rechner abarbeiten — Windows, macOS oder Linux — das dauert 20 Minuten, danach das Kapitel „Der Editor: VSCodium“. Das Kapitel „Wo tippe ich das eigentlich ein?“ solltest du gelesen haben, auch wenn der Rechner schon fertig eingerichtet ist — sonst kosten dich die ersten Fehlermeldungen heute unnötig Zeit. Wer noch nie mit einem Terminal gearbeitet hat, nimmt zusätzlich das Kapitel „Die Shell: das Minimum, das trägt“ mit.

Warum Python 3.12 und nicht die neueste Version? Weil es für Python 3.14 und neuer kein fertiges pygame-Paket gibt. Die Installation versucht dann, pygame selbst zu übersetzen, und scheitert. Deshalb steht in den Befehlen unten überall --python 3.12.

🖥️ Terminal (PowerShell unter Windows, Terminal unter Mac)

mkdir schiffe
cd schiffe
uv venv --python 3.12
uv pip install pygame

Kontrolle, bevor es weitergeht:

🖥️ Terminal

uv run python -c "import pygame; print(pygame.version.ver)"

Erscheint eine Versionsnummer, ist alles bereit. Erscheint eine Fehlermeldung, lohnt der Blick ins Kapitel „Erste Hilfe“ der Systemeinrichtung — dort stehen die vier häufigsten mit Lösung.

📄 Datei test.py — im Editor anlegen, im Ordner schiffe speichern:

import pygame

pygame.init()
fenster = pygame.display.set_mode((800, 600))
pygame.display.set_caption("Schiffe versenken")

laeuft = True
while laeuft:
    for ereignis in pygame.event.get():
        if ereignis.type == pygame.QUIT:
            laeuft = False
    fenster.fill((10, 20, 40))
    pygame.display.flip()

pygame.quit()

Starten — Datei speichern (Strg+S), dann:

🖥️ Terminal

uv run test.py

Nicht python test.py — sonst läuft ein anderes Python, in dem pygame fehlt, und du bekommst ModuleNotFoundError. Das Fenster schließt du übers Kreuz oder mit Strg+C im Terminal.

Aufgabe: Ändere die Fenstergröße und die Hintergrundfarbe. Finde heraus, was (10, 20, 40) bedeutet.

💡 Farben sind Vektoren. RGB ist ein 3-Tupel im Wertebereich 0–255. (255, 0, 0) ist knallrot. Du kannst später zwischen zwei Farben interpolieren – genau wie eine lineare Interpolation in Mathe: farbe = a + t * (b - a) mit t zwischen 0 und 1. Damit machst du einen Treffer-Blitz, der sanft ausblendet.

Kapitel A — Typen, kurz und schmerzhaft ehrlich (20 min)

Kein Frontalunterricht: Dieses Kapitel wird getippt, nicht gelesen.

Dafür brauchst du die Python-Konsole — den Ort, an dem du eine Zeile Python eingibst und sofort die Antwort bekommst, ohne etwas zu speichern. Sie startet aus dem Terminal heraus:

🖥️ Terminal

uv run python

Der Prompt wechselt daraufhin auf >>>. Das ist das Zeichen, dass du jetzt in Python bist und nicht mehr im Terminal:

Python 3.12.11 (main, …)
Type "help", "copyright", "credits" or "license" for more information.
>>>

Alles, was in diesem Kapitel mit 🐍 markiert ist, gehört hinter dieses >>>. Das Zeichen selbst tippst du nicht mit — es steht schon da. Die Antwort erscheint direkt darunter, ganz ohne print():

🐍 Python-Konsole

>>> 2 + 3
5
>>> len("Kreuzer")
7

Wieder heraus kommst du mit exit(). Und wenn eine Fehlermeldung sagt, dein Befehl sei kein Cmdlet (Windows) oder zsh: number expected (Mac), dann hast du Python-Code ins Terminal getippt — Prompt anschauen, uv run python, nochmal.

💡 Diese Konsole ist kein Anfängerwerkzeug, das man später ablegt. Auch nach dreißig Jahren ist sie der schnellste Weg, eine Frage wie „was gibt sorted() bei einem Set zurück?“ in vier Sekunden zu beantworten, statt drei Minuten zu suchen.

Die Typen, die du heute brauchst

Typ Beispiel Änderbar? Wofür heute
int 7 Zeilen, Spalten, Pixel
float 3.5 Animationen, Zeit
bool True „getroffen?“
str "Kreuzer" nein Namen, Anzeigen
tuple (3, 5) nein eine Koordinate
list [(3,5), (3,6)] ja Liste von Koordinaten
dict {"C4": "Treffer"} ja Nachschlagetabellen
set {(3,5), (1,2)} ja „schon beschossen?“
None None „hier ist nichts“

Die drei Erkenntnisse, die wirklich zählen

1. Tupel vs. Liste ist keine Geschmacksfrage.

🐍 Python-Konsole

position = (3, 5)        # eine Koordinate: ein Ding, unveränderlich
treffer  = [(3, 5)]      # eine Sammlung: wächst

Eine Koordinate ist wie ein Punkt im ℝ² – der Punkt (3,5) ist (3,5), man ändert ihn nicht, man nimmt einen anderen. Deshalb Tupel. Bonus: nur Tupel dürfen in ein set oder als dict-Schlüssel.

2. set ist eine Superkraft.

🐍 Python-Konsole

beschossen = set()
beschossen.add((3, 5))
(3, 5) in beschossen     # True — und das in O(1), egal wie groß die Menge ist

Bei einer Liste muss Python jedes Element durchgehen. Beim Set rechnet es die Position direkt aus (Hashing). Für „hab ich hier schon geschossen?“ ist das der richtige Typ.

3. Typannotationen sind Definitionsbereiche.

🐍 Python-Konsole

def entfernung(a: tuple[int, int], b: tuple[int, int]) -> float:
    ...

Das ist nichts anderes als f: ℤ² × ℤ² → ℝ aus der Mathe. Python erzwingt es nicht, aber dein Editor warnt dich und dein zukünftiges Ich versteht den Code noch. Ab heute schreibst du sie überall hin.

Mini-Übungen (5 min)

🐍 Python-Konsole — Zeile für Zeile eintippen und jeweils die Antwort ansehen:

# 1. Was passiert hier? Erkläre es.
a = [1, 2, 3]
b = a
b.append(4)
print(a)

# 2. Und warum hier nicht?
x = (1, 2, 3)
y = x
# y.append(4)   ← probier's aus, lies die Fehlermeldung

# 3. Baue ein set mit allen 100 Feldern eines 10x10-Bretts – in EINER Zeile.

Kapitel B — Der Aha-Moment: Warum Objekte? (20 min)

Erst der Schmerz. So sieht Schiffe versenken sequenziell aus:

feld = [[0]*10 for _ in range(10)]
schiff1_zeilen = [2, 3, 4]
schiff1_spalte = 5
schiff1_treffer = 0
schiff2_zeilen = [7]
schiff2_spalte = 1
schiff2_treffer = 0
# ... und jetzt kommt Schiff 3, 4, 5 ...

Frage: „Wir bekommen jetzt 5 Schiffe pro Spieler und 2 Spieler. Wie viele Variablen sind das? Und was passiert, wenn du die Schiffslänge nachträglich änderst?“

Spätestens beim Durchzählen wird klar, dass das nicht skaliert. Dann kommt die Auflösung:

Ein Objekt ist ein Substantiv, eine Methode ist ein Verb. Schreib dein Spiel als Satz auf: „Das Spielfeld hat Schiffe. Ein Schiff wird getroffen und ist irgendwann versenkt.“ Substantive → Klassen. Verben → Methoden. Fertig ist der Entwurf.

Die Klassen-Mechanik in 10 Zeilen

🐍 Python-Konsole

class Katze:
    def __init__(self, name: str):   # Konstruktor: läuft beim Erzeugen
        self.name = name             # Attribut: gehört DIESEM Objekt
        self.satt = False

    def fressen(self) -> None:       # Methode: Verb
        self.satt = True
        print(f"{self.name} ist satt.")

mia = Katze("Mia")     # Instanz erzeugen
tom = Katze("Tom")     # zweite Instanz — eigene Attribute!
mia.fressen()
print(tom.satt)        # False — Tom hat nichts abbekommen

Was ist self? Die Klasse ist der Bauplan, das Objekt das gebaute Haus. self ist „dieses konkrete Haus hier“. Python schiebt es bei jedem Methodenaufruf automatisch als erstes Argument rein – mia.fressen() wird intern zu Katze.fressen(mia). Deshalb steht es in der Definition, aber nicht im Aufruf.

Die häufigste Anfängerfalle:

def fressen(self):
    satt = True          # ❌ lokale Variable, verschwindet sofort
    self.satt = True     # ✅ Attribut des Objekts, bleibt

Kein self = kein Gedächtnis. Das ist der ganze Unterschied.

Meilenstein 1 — Die Klasse Schiff (45 min)

Ziel: Ein Schiff weiß, wo es liegt, ob es getroffen wurde und ob es versenkt ist. Ganz ohne Grafik.

Datei schiff.py – das ist das Gerüst, die Logik schreibst du:

📄 Datei

class Schiff:
    """Ein Schiff auf dem Spielfeld.

    Attribute:
        name:      z.B. "Zerstörer"
        felder:    Liste der belegten Koordinaten, z.B. [(2,5), (3,5), (4,5)]
        treffer:   Menge der bereits getroffenen Koordinaten
    """

    def __init__(self, name: str, felder: list[tuple[int, int]]):
        self.name = name
        self.felder = felder
        self.treffer: set[tuple[int, int]] = set()

    def laenge(self) -> int:
        """Wie viele Felder belegt das Schiff?"""
        # TODO
        ...

    def belegt(self, feld: tuple[int, int]) -> bool:
        """Liegt dieses Feld auf dem Schiff?"""
        # TODO
        ...

    def beschiessen(self, feld: tuple[int, int]) -> bool:
        """Trägt einen Treffer ein. Gibt True zurück, wenn getroffen wurde."""
        # TODO
        ...

    def versenkt(self) -> bool:
        """Sind alle Felder getroffen?"""
        # TODO
        ...

    def __repr__(self) -> str:
        """Was print() anzeigt — Gold wert beim Debuggen."""
        return f""

Testen — nicht raten, ausprobieren!

Deine Klasse steht in einer Datei, z. B. schiff.py. Zum Ausprobieren startest du die Python-Konsole im selben Ordner und importierst sie:

🖥️ Terminal

uv run python

🐍 Python-Konsole

>>> from schiff import Schiff
>>> s = Schiff("Zerstörer", [(2,5), (3,5), (4,5)])
>>> s.laenge()
3

Der Import holt deine Klasse aus der Datei in die Konsole. Ändert sich die Datei, musst du die Konsole neu starten (exit(), dann wieder uv run python) — ein Import wird nicht automatisch aufgefrischt. Das ist der häufigste Grund für „aber ich hab's doch repariert, es kommt trotzdem der alte Fehler“.

Die ganze Prüfreihe:

🐍 Python-Konsole

s = Schiff("Zerstörer", [(2,5), (3,5), (4,5)])
print(s.laenge())              # 3
print(s.beschiessen((3,5)))    # True
print(s.beschiessen((9,9)))    # False
print(s.versenkt())            # False
s.beschiessen((2,5)); s.beschiessen((4,5))
print(s.versenkt())            # True
print(s)                       # nutzt __repr__

Extra für Schnelle: Schreib die Klasse als @dataclass neu (from dataclasses import dataclass, field). Sie spart __init__ und __repr__. Frage: Warum braucht man für treffer dann field(default_factory=set) und nicht = set()?

Checkpoint 1 — Prüffragen: - Warum ist treffer ein set und keine list? - Was passiert, wenn man zweimal auf dasselbe Feld schießt? - Wo im Code steht die Regel „ein Schiff ist versenkt, wenn alle Felder getroffen sind“? Genau einmal – oder mehrfach?

Meilenstein 2 — Die Klasse Spielfeld (60 min)

Das ist der wichtigste Meilenstein des Tages. Am Ende ist das Spiel im Terminal komplett spielbar – und zwar ohne eine einzige Zeile Pygame. Das beweist Hausregel 2.

Datei spielfeld.py:

📄 Datei

from enum import Enum
from schiff import Schiff

class Ergebnis(Enum):
    """Was ein Schuss bewirkt hat."""
    DANEBEN = "daneben"
    TREFFER = "Treffer"
    VERSENKT = "versenkt"
    UNGUELTIG = "ungültig"     # außerhalb oder schon beschossen

class Spielfeld:
    def __init__(self, groesse: int = 10):
        self.groesse = groesse
        self.schiffe: list[Schiff] = []
        self.beschossen: set[tuple[int, int]] = set()

    # --- Aufbau ---------------------------------------------------------
    def passt(self, felder: list[tuple[int, int]]) -> bool:
        """Prüft: alle Felder im Brett UND kein anderes Schiff im Weg."""
        # TODO
        ...

    def setze_schiff(self, schiff: Schiff) -> bool:
        """Fügt das Schiff hinzu, wenn es passt. Gibt Erfolg zurück."""
        # TODO
        ...

    # --- Spielablauf ----------------------------------------------------
    def schuss(self, feld: tuple[int, int]) -> Ergebnis:
        """Der Kern des Spiels. Genau EINE Methode ändert den Zustand."""
        # TODO: 1) gültig?  2) merken  3) Schiff suchen  4) Ergebnis
        ...

    def alle_versenkt(self) -> bool:
        # TODO
        ...

    def zeige(self) -> str:
        """Das Brett als Text — zum Testen und Debuggen."""
        # TODO: Kopfzeile A-J, dann Zeilen mit ~ (Wasser), X (Treffer), o (daneben)
        ...

Warum ein Enum und keine Strings? Weil "Treffer" vs. "treffer" vs. "Treffe" stumme Fehler sind, die erst um 16 Uhr auffallen. Ergebnis.TREFFER gibt es entweder – oder Python meckert sofort.

Aufgabe: Bau daraus ein Terminal-Spiel (konsole.py, ca. 20 Zeilen):

   A B C D E F G H I J
 1 ~ ~ ~ ~ ~ ~ ~ ~ ~ ~
 2 ~ ~ o ~ ~ ~ ~ ~ ~ ~
 3 ~ ~ ~ ~ X ~ ~ ~ ~ ~
Dein Schuss: E3
>>> Treffer!

Eingabe "E3" → Tupel (2, 4) umrechnen. Achtung: Menschen zählen ab 1, Python ab 0.

📄 Datei konsole.py — gestartet wird sie so:

🖥️ Terminal

uv run konsole.py

Ab hier läuft dein Programm und fragt dich nach Eingaben. Das ist kein dritter Ort: Du bist im Terminal, aber dein eigenes Programm stellt die Fragen. Mit Strg+C brichst du ab.

Checkpoint 2 — Prüffragen: - Steht irgendwo import pygame in diesen beiden Dateien? (Nein? Perfekt.) - Wenn ich das Brett auf 15×15 vergrößern will – wie viele Stellen muss ich anfassen? - schuss() ist die einzige Methode, die etwas verändert. Warum ist das gut?

Der Motivations-Moment: Das Spiel ist fertig und funktioniert. Alles Weitere ist nur noch eine hübsche Oberfläche für Code, der bereits läuft.

Meilenstein 3 — Pygame und die Game Loop (45 min)

Ziel: Fenster mit Raster, sauber in einer Klasse gekapselt.

Das Herzstück jedes Spiels

Jedes Spiel der Welt – Tetris, GTA, Minecraft – macht 60-mal pro Sekunde dasselbe:

┌─────────────────────────────────────────┐
│  1. EREIGNISSE    (Was tut der Mensch?) │
│  2. AKTUALISIEREN (Was folgt daraus?)   │
│  3. ZEICHNEN      (Wie sieht es aus?)   │
└─────────────────────────────────────────┘
        ↑ und wieder von vorn

🔬 Physik-Bezug: Das ist numerische Integration in Zeitschritten Δt. Wer schon mal eine Wurfparabel simuliert hat, kennt das Muster: Zustand → kleiner Zeitschritt → neuer Zustand. Ein Spiel ist ein Physiksimulator, der auf Mausklicks hört.

Datei spiel.py:

📄 Datei

import pygame
from spielfeld import Spielfeld, Ergebnis

ZELLE = 40           # Pixel pro Kästchen
RAND = 60            # Abstand vom Fensterrand
WASSER = (12, 34, 68)
LINIE = (60, 90, 130)

class Spiel:
    def __init__(self, groesse: int = 10):
        pygame.init()
        self.feld = Spielfeld(groesse)
        breite = 2 * RAND + groesse * ZELLE
        self.fenster = pygame.display.set_mode((breite, breite))
        self.uhr = pygame.time.Clock()
        self.laeuft = True

    def ereignisse(self) -> None:
        for e in pygame.event.get():
            if e.type == pygame.QUIT:
                self.laeuft = False
            # TODO später: Mausklick

    def aktualisieren(self) -> None:
        # TODO später: Animationen
        ...

    def zeichnen(self) -> None:
        self.fenster.fill(WASSER)
        self._raster_zeichnen()
        pygame.display.flip()

    def _raster_zeichnen(self) -> None:
        """Der Unterstrich heißt: interne Methode, von außen nicht anfassen."""
        # TODO: groesse+1 senkrechte und waagerechte Linien
        # pygame.draw.line(flaeche, farbe, start, ende)
        ...

    def start(self) -> None:
        while self.laeuft:
            self.ereignisse()
            self.aktualisieren()
            self.zeichnen()
            self.uhr.tick(60)      # max. 60 Bilder pro Sekunde
        pygame.quit()

if __name__ == "__main__":
    Spiel().start()

Frage zum Nachdenken: Was passiert, wenn du self.uhr.tick(60) weglässt? (Antwort: Der Lüfter dreht auf. Die Schleife rennt so schnell sie kann.)

Was bedeutet if __name__ == "__main__":? Diese Zeile läuft nur, wenn diese Datei direkt gestartet wird – nicht beim Import. Deshalb kannst du spielfeld.py importieren, ohne dass ein Fenster aufgeht.

Meilenstein 4 — Koordinaten-Mathematik (45 min)

Ziel: Ein Mausklick bei Pixel (327, 214) landet im richtigen Kästchen.

Das ist reine Mathematik und der leichteste Teil des Tages. Zwei Abbildungen, die zueinander invers sind:

Gitter → Pixel:   px = RAND + spalte * ZELLE
                  py = RAND + zeile  * ZELLE

Pixel → Gitter:   spalte = (mx - RAND) // ZELLE
                  zeile  = (my - RAND) // ZELLE

// ist Ganzzahldivision (abrunden) – genau das Richtige, denn alles zwischen Pixel 60 und 99 gehört zu Spalte 0.

📄 Datei — in spiel.py ergänzen

def gitter_zu_pixel(self, feld: tuple[int, int]) -> tuple[int, int]:
    """(zeile, spalte) -> linke obere Ecke in Pixeln."""
    # TODO
    ...

def pixel_zu_gitter(self, pos: tuple[int, int]) -> tuple[int, int] | None:
    """Maus-Pixel -> (zeile, spalte), oder None wenn außerhalb des Bretts."""
    # TODO: erst umrechnen, DANN prüfen ob 0 <= wert < groesse
    ...

Die Falle: (-5) // 40 ergibt in Python -1, nicht 0. Klickt man links neben das Brett, kommt Spalte -1 heraus – und liste[-1] ist in Python das letzte Element. Der Klick daneben trifft also stillschweigend die rechte Bretthälfte. Deshalb immer die Bereichsprüfung nach der Division. Genau dafür gibt es den Rückgabewert None.

Aufgabe: Zeichne das Kästchen unter dem Mauszeiger hell ein (pygame.mouse.get_pos(), pygame.draw.rect). Wenn das flüssig mitläuft, sind beide Richtungen korrekt.

Checkpoint 3: Klick in alle vier Ecken und knapp daneben. Kein Absturz, kein falsches Feld? Dann weiter.

Meilenstein 5 — Schießen und Zeichnen (60 min)

Jetzt kommt beides zusammen. In ereignisse():

📄 Datei — in spiel.py ergänzen

if e.type == pygame.MOUSEBUTTONDOWN and e.button == 1:
    feld = self.pixel_zu_gitter(e.pos)
    if feld is not None:
        ergebnis = self.feld.schuss(feld)      # Logik aus Meilenstein 2!
        # TODO: Ergebnis auswerten (Sound? Animation? Text?)

Achte darauf: Hier steht keine Spielregel. Die Frage „war das ein Treffer?“ beantwortet Spielfeld. Die Oberfläche fragt nur und malt das Ergebnis. Das ist Hausregel 2 in Aktion.

Zeichnen:

Zustand Darstellung
unbeschossen Wasser
daneben weißer Punkt
Treffer rotes X oder Flammen-Emoji
versenktes Schiff ganzes Schiff grau/schwarz durchgestrichen

Text anzeigen:

📄 Datei — in spiel.py ergänzen

self.schrift = pygame.font.SysFont("Arial", 22)     # einmal in __init__!
bild = self.schrift.render("Treffer!", True, (255, 220, 100))
self.fenster.blit(bild, (RAND, 20))

⚠️ SysFont in der Zeichenschleife anzulegen kostet 60-mal pro Sekunde eine Schriftart. Merksatz: Was sich nicht ändert, gehört in __init__.

Checkpoint 4: Grafisch spielbar. Guter Zeitpunkt für eine Pause.

Meilenstein 6 — Schiffe platzieren (45 min)

Klassische Flotte: 1×5, 1×4, 2×3, 1×2.

📄 Datei

import random

def platziere_zufaellig(feld: Spielfeld, flotte: dict[str, list[int]]) -> None:
    """Setzt alle Schiffe zufällig, ohne Überlappung."""
    # TODO:
    #   für jedes Schiff:
    #     wiederhole:
    #       zufällige Startzelle + zufällige Richtung ((0,1) oder (1,0))
    #       Felder ausrechnen
    #       wenn feld.passt(...): setzen, fertig
    ...

Die Denkfalle: Eine while True-Schleife, die immer wieder würfelt, hängt sich auf, wenn kein Platz mehr da ist. Bau einen Zähler ein (max. 500 Versuche) und beginne notfalls das ganze Brett neu.

🎲 Zahlenspiel: Wie viele legale Positionen gibt es für ein 5er-Schiff auf einem leeren 10×10-Brett? Waagerecht: 6 pro Zeile × 10 Zeilen = 60. Senkrecht ebenso. Macht 120. Diese Zahl brauchst du in Meilenstein 7 wieder – merk sie dir.

Ausbaustufe (optional): Der Mensch platziert selbst per Maus, R dreht das Schiff, Vorschau in Grün/Rot je nach passt().

Meilenstein 7 — Der Gegner in drei Stufen (60 min)

Das ist der Höhepunkt des Tages. Drei KI-Stufen, jede eine eigene Klasse mit derselben Methode naechster_schuss(). Beim Start wählt man aus – und sieht sofort, wie viel schlauer die nächste Stufe ist.

📄 Datei ki.py

class KI:
    """Basisklasse — legt fest, was jede KI können muss."""
    def naechster_schuss(self, gegner: Spielfeld) -> tuple[int, int]:
        raise NotImplementedError

Stufe 1 – Zufall. Würfelt ein Feld, das noch nicht beschossen ist. → Braucht im Schnitt ~96 Schüsse für 100 Felder.

Stufe 2 – Jäger. Zwei Modi: Solange nichts getroffen wurde, zufällig schießen. Nach einem Treffer die vier Nachbarfelder abarbeiten. Nach zwei Treffern in einer Linie: in dieser Richtung weiter. → ~65 Schüsse.

Stufe 3 – Wahrscheinlichkeitskarte. Für jedes noch nicht versenkte Schiff werden alle legalen Positionen durchgezählt. Jedes Feld bekommt einen Zähler: „auf wie vielen möglichen Schiffspositionen liege ich?“ Dann wird auf das Maximum geschossen.

📄 Datei ki.py

def wahrscheinlichkeitskarte(gegner, restliche_laengen: list[int]):
    karte = [[0] * gegner.groesse for _ in range(gegner.groesse)]
    # TODO für jede Länge, jede Startzelle, jede Richtung:
    #   passt die Position noch zu allem, was wir wissen?
    #   wenn ja: +1 auf alle ihre Felder
    return karte

~45 Schüsse. Und das Schönste: Man kann die Karte einfärben. Zeichne sie halbtransparent über das Brett – dunkelblau = unwahrscheinlich, hellgelb = heiß. Man sieht dem Computer beim Denken zu.

🔥 Das ist der Moment, in dem es Klick macht. Kein „ich habe Code abgetippt“, sondern: Ich habe eine Idee aus der Stochastik in Code übersetzt, und der Computer wurde dadurch messbar besser. Genau das ist Informatik.

Und hier zahlt sich das Objektdesign aus: Drei völlig verschiedene Gegner, ein Austausch einer einzigen Zeile (self.ki = Stufe3()). Im Spaghetticode hätte das ein if-Monster mit 40 Zeilen gebraucht.

Wettbewerb zum Abschluss: Lass alle drei KIs je 100 Spiele gegen zufällig platzierte Flotten laufen (ohne Grafik – geht in Sekunden, weil die Logik pygame-frei ist!) und trage den Schnitt in eine Tabelle ein. Das ist eine echte Messreihe, wie im Physikpraktikum.

Meilenstein 8 — Politur (Rest des Tages)

Freie Auswahl, alles optional:

  • Sound: pygame.mixer.Sound("treffer.wav").play() – Explosion, Platsch, Sieges-Fanfare
  • Animation: Treffer-Blitz, der über 0,3 s ausblendet (Farbinterpolation aus Meilenstein 0)
  • Endbildschirm: „Du hast gewonnen – in 43 Schüssen!“
  • Eigenes Thema: Raumschiffe statt Boote, Weltraum statt Meer. Kostet nur Farben und Beschriftungen – weil die Logik davon nichts weiß.
  • Statistik: Trefferquote in Prozent, live oben rechts

Anhang A — Fehlermeldungen lesen (5 min, aber wichtig)

Fehlermeldungen sind keine Beleidigung, sondern eine Wegbeschreibung. Immer von unten nach oben lesen: Die letzte Zeile sagt was, die vorletzte wo.

Meldung Bedeutet meistens
AttributeError: 'Schiff' object has no attribute 'x' Tippfehler oder self.x nie gesetzt
TypeError: ... missing 1 required positional argument: 'self' Methode über die Klasse statt über das Objekt aufgerufen
IndexError: list index out of range Bereichsprüfung vergessen (siehe Meilenstein 4!)
TypeError: unhashable type: 'list' Liste statt Tupel als Koordinate benutzt
NameError: name 'x' is not defined Vertippt, oder Variable lebt in einem anderen Block

Die zweite Sorte Fehler kommt nicht aus deinem Code, sondern daraus, dass etwas am falschen Ort steht:

Meldung Bedeutet
ModuleNotFoundError: No module named 'pygame' mit python … statt uv run … gestartet
SyntaxError bei einem uv- oder cd-Befehl Terminal-Befehl in der Python-Konsole → exit()
… nicht als Name eines Cmdlets erkannt / zsh: number expected Python-Code im Terminal → uv run python
ModuleNotFoundError: No module named 'schiff' Python-Konsole läuft in einem anderen Ordner als die Datei
Änderung wirkt nicht, alter Fehler bleibt Konsole neu starten — ein Import wird nicht aufgefrischt

Ausführlich stehen die im Kapitel „Erste Hilfe“ der Systemeinrichtung.

Wenn gar nichts geht: print() ist kein Zeichen von Schwäche. Drei gut gesetzte print() schlagen zwanzig Minuten Starren. Und print(self.feld) verrät dir alles – wenn du __repr__ geschrieben hast.

Anhang B — Spickzettel

# Klasse
class Ding:
    def __init__(self, wert):
        self.wert = wert            # Attribut
    def machen(self):               # Methode
        return self.wert * 2

d = Ding(21)
d.machen()                          # 42

# Listen & Mengen
felder = [(0,0), (0,1)]
felder.append((0,2))
menge = set(felder)
(0,1) in menge                      # schnelle Prüfung

# Comprehension
quadrate = [x**2 for x in range(5)]           # [0,1,4,9,16]
brett = [[0]*10 for _ in range(10)]           # 10x10 (NICHT [[0]*10]*10 !)

# f-String
print(f"{name}: {treffer}/{gesamt} ({treffer/gesamt:.1%})")

# Pygame
pygame.draw.rect(flaeche, farbe, (x, y, breite, hoehe))
pygame.draw.line(flaeche, farbe, (x1,y1), (x2,y2), dicke)
pygame.draw.circle(flaeche, farbe, (x,y), radius)

⚠️ [[0]*10]*10 ist die fieseste Falle in Python. Das erzeugt zehnmal dieselbe Zeile. Änderst du brett[0][3], ändern sich alle zehn Zeilen. Immer die Comprehension nehmen.

Anhang C — Wenn es hakt: die Hilfeleiter

Nicht die Lösung geben. In dieser Reihenfolge helfen:

  1. „Lies die Fehlermeldung laut vor. Welche Zeile?“
  2. „Was erwartest du, dass hier drinsteht? Setz ein print() davor.“
  3. „Erklär mir die Methode Satz für Satz.“ (Rubber-Duck-Debugging – funktioniert erschreckend oft schon bei Satz zwei)
  4. „Welche der drei Hausregeln verletzt der Code hier gerade?“
  5. Erst jetzt: konkreter Hinweis auf die Zeile – nie die fertige Zeile selbst.

Abschluss (15 min)

Das Spiel wird vorgeführt und in drei Sätzen erklärt: Was macht Schiff, was macht Spielfeld, was macht Spiel? Wer das beantworten kann, hat an einem Tag verstanden, wofür manche ein Semester brauchen: Ein Programm ist kein Ablauf von oben nach unten. Es ist ein Ensemble von Dingen, die zusammenspielen.

Und zum Schluss die Frage, die hängen bleibt: „Deine Stufe-3-KI braucht 45 statt 96 Schüsse. Du hast den Computer heute doppelt so schlau gemacht — mit Mathe. Willst du wissen, was passiert, wenn man das auf echte Probleme loslässt?“