Projekt A · Teil 2 · Stufe 1 – Swift-Fundament

Zahlenraten im Playground (Teil 2)

ca. 15 Minuten Lesezeit

Lernziel

In diesem Projektteil räumst du das Zahlenraten-Spiel auf: mit einem Enum für die Hinweise, Funktionen für wiederkehrende Logik, einer Struct für das Spiel selbst, Optionals für Texteingaben und einem Schwierigkeitsgrad. Zum Schluss lässt du den Computer 1.000 Spiele spielen und wertest sie aus.

Ausgangslage

Am Ende von Teil 1 hatten wir ein funktionierendes Spiel, aber auch drei Schwächen:

  1. Der Vergleich „zu klein / zu groß / richtig“ steht mehrfach im Code.
  2. Alles steckt in einem langen Block und lässt sich nicht wiederverwenden.
  3. Echte Eingaben sind Text, und Text ist nicht immer eine Zahl.

Diese Art von Überarbeitung, bei der sich das Verhalten nicht ändert, der Code aber besser wird, nennt man Refactoring. Profis machen das ständig.

Leg für Teil 2 einen neuen Playground an oder lösch den Inhalt des alten. In diesem Teil wächst der Code Schritt für Schritt in einem Playground. Wo eine Lösung einen bestehenden Typ in neuer Version zeigt, ersetzt du die alte Version.

Schritt 1: Ein Enum für die Hinweise

Bisher war ein Hinweis nur ein Text, den wir ausgeben. Aber der Computer-Spieler muss mit dem Hinweis weiterarbeiten: Bei „zu klein“ verschiebt er die untere Grenze. Texte zu vergleichen wäre fehleranfällig (Lektion 1.14).

Anfänger

Aufgabe

Definiere ein Enum Hint mit den Fällen tooLow, tooHigh und correct. Gib ihm eine berechnete Eigenschaft text, die für jeden Fall eine passende deutsche Meldung liefert.

LösungErst selbst probieren!
Swift
enum Hint {
    case tooLow
    case tooHigh
    case correct

    var text: String {
        switch self {
        case .tooLow: "zu klein"
        case .tooHigh: "zu groß"
        case .correct: "richtig! 🎉"
        }
    }
}

Schritt 2: Eine Funktion für den Vergleich

Anfänger

Aufgabe

Schreib eine Funktion evaluate(_:secret:), die einen Tipp und die Geheimzahl bekommt und den passenden Hint zurückgibt. Teste sie mit drei Aufrufen.

LösungErst selbst probieren!
Swift
func evaluate(_ guess: Int, secret: Int) -> Hint {
    if guess < secret {
        .tooLow
    } else if guess > secret {
        .tooHigh
    } else {
        .correct
    }
}

print(evaluate(30, secret: 37).text)   // zu klein
print(evaluate(50, secret: 37).text)   // zu groß
print(evaluate(37, secret: 37).text)   // richtig! 🎉

Hier ist if ein Ausdruck (Lektion 1.4): Jeder Zweig liefert direkt einen Wert, ganz ohne return. Der Aufruf evaluate(30, secret: 37) liest sich dank des fehlenden ersten Labels fast wie ein Satz.

Die Vergleichslogik steht jetzt an genau einer Stelle. Soll sie sich ändern, änderst du nur diese Funktion.

Schritt 3: Das Spiel als Struct

Ein Spiel hat Daten (Geheimzahl, Versuche, ob es vorbei ist) und Verhalten (einen Tipp abgeben). Das ist ein perfekter Fall für eine Struct (Lektion 1.12).

Fortgeschritten

Aufgabe

Baue eine Struct Game mit:

  • einer Konstante secret, die standardmäßig zufällig zwischen 1 und 100 liegt,
  • einer Variable attempts, die bei 0 beginnt,
  • einer Variable isOver, die bei false beginnt,
  • einer mutating-Methode guess(_:) -> Hint, die den Versuch zählt, das Ergebnis mit evaluate ermittelt, bei .correct das Spiel beendet und den Hinweis zurückgibt.
LösungErst selbst probieren!
Swift
struct Game {
    let secret: Int
    var attempts = 0
    var isOver = false

    init(secret: Int = Int.random(in: 1...100)) {
        self.secret = secret
    }

    mutating func guess(_ number: Int) -> Hint {
        attempts += 1
        let hint = evaluate(number, secret: secret)
        if hint == .correct {
            isOver = true
        }
        return hint
    }
}

var game = Game(secret: 37)
for number in [50, 25, 37] {
    let hint = game.guess(number)
    print("Versuch \(game.attempts): \(number) ist \(hint.text)")
}
print("Spiel vorbei: \(game.isOver)")

Ein paar Details:

  • Der eigene init mit Standardwert erlaubt beides: Game() für ein echtes Spiel mit Zufallszahl und Game(secret: 37) für vorhersagbare Tests.
  • hint == .correct funktioniert, weil Enums ohne Associated Values automatisch vergleichbar sind.
  • guess muss mutating sein, weil es attempts und isOver verändert. Und game muss mit var angelegt sein.

Schritt 4: Texteingaben mit Optionals

Ein echter Spieler tippt Text in ein Feld, später in deiner App zum Beispiel in ein TextField. Und Text kann alles Mögliche sein: „42“, „ 42 “, „zweiundvierzig“ oder „500“.

Fortgeschritten

Aufgabe

Schreib eine Funktion parseGuess(_ input: String) -> Int?, die:

  • Leerzeichen am Anfang und Ende ignoriert (Tipp: input.trimmingCharacters(in: .whitespaces), dafür braucht der Playground ganz oben import Foundation oder import UIKit),
  • nil zurückgibt, wenn der Text keine Zahl ist,
  • nil zurückgibt, wenn die Zahl nicht zwischen 1 und 100 liegt,
  • sonst die Zahl zurückgibt.

Verwende guard let. Teste mit "42", " 7 ", "abc" und "500".

LösungErst selbst probieren!
Swift
import Foundation

func parseGuess(_ input: String) -> Int? {
    let trimmed = input.trimmingCharacters(in: .whitespaces)

    guard let number = Int(trimmed) else {
        return nil
    }
    guard (1...100).contains(number) else {
        return nil
    }
    return number
}

for input in ["42", " 7 ", "abc", "500"] {
    if let number = parseGuess(input) {
        print("„\(input)“ → \(number)")
    } else {
        print("„\(input)“ ist kein gültiger Tipp")
    }
}

(1...100).contains(number) prüft, ob die Zahl im Bereich liegt. Bereiche haben, wie Arrays, eine contains-Methode.

Jetzt kann das Spiel mit Texteingaben umgehen:

Swift
var textGame = Game(secret: 37)
let inputs = ["50", "hallo", "25", "999", "37"]

for input in inputs {
    guard let number = parseGuess(input) else {
        print("„\(input)“ wird ignoriert. Bitte eine Zahl von 1 bis 100 eingeben.")
        continue
    }

    let hint = textGame.guess(number)
    print("Versuch \(textGame.attempts): \(number) ist \(hint.text)")

    if textGame.isOver {
        break
    }
}

Hier steht guard direkt in einer Schleife. Statt return beendet continue den aktuellen Durchlauf. Auch das ist ein gültiger Ausstieg für guard. Ungültige Eingaben zählen übrigens nicht als Versuch, denn guess wird gar nicht erst aufgerufen.

Optional oder Fehler?

Hier reicht ein Optional, weil der Spieler nur wissen muss: „Das war kein gültiger Tipp.“ Willst du unterscheiden zwischen „keine Zahl“ und „außerhalb des Bereichs“, wäre eine Funktion mit throws und einem eigenen Fehler-Enum besser (Lektion 1.17). Das ist eine schöne Bonus-Aufgabe.

Schritt 5: Schwierigkeitsgrade

Fortgeschritten

Aufgabe

Definiere ein Enum Difficulty mit easy, medium und hard, das CaseIterable erfüllt. Gib ihm eine berechnete Eigenschaft range, die den Zahlenbereich liefert: 1–10, 1–100 und 1–1000. Passe Game so an, dass es mit einem Schwierigkeitsgrad erzeugt wird.

Tipp: Der Typ eines Bereichs wie 1...100 heißt ClosedRange<Int>. Erkennst du die spitzen Klammern aus Lektion 1.18?

LösungErst selbst probieren!
Swift
enum Difficulty: String, CaseIterable {
    case easy = "Leicht"
    case medium = "Mittel"
    case hard = "Schwer"

    var range: ClosedRange<Int> {
        switch self {
        case .easy: 1...10
        case .medium: 1...100
        case .hard: 1...1000
        }
    }
}

struct Game {
    let difficulty: Difficulty
    let secret: Int
    var attempts = 0
    var isOver = false

    init(difficulty: Difficulty = .medium, secret: Int? = nil) {
        self.difficulty = difficulty
        self.secret = secret ?? Int.random(in: difficulty.range)
    }

    mutating func guess(_ number: Int) -> Hint {
        attempts += 1
        let hint = evaluate(number, secret: secret)
        if hint == .correct {
            isOver = true
        }
        return hint
    }
}

for difficulty in Difficulty.allCases {
    let game = Game(difficulty: difficulty)
    print("\(difficulty.rawValue): Zahl zwischen \(difficulty.range.lowerBound) und \(difficulty.range.upperBound)")
}

ClosedRange<Int> ist ein generischer Typ (Lektion 1.18): ein geschlossener Bereich aus Int-Werten. lowerBound und upperBound sind seine untere und obere Grenze.

Ersetze die alte Game-Struct aus Schritt 3 durch diese Version. Der neue init nimmt eine optionale Geheimzahl: Gibst du keine an, wird gewürfelt (secret ?? ..., Lektion 1.9). So funktionieren deine Tests mit Game(secret: 37) aus den Schritten 3 und 4 weiterhin.

Xcode warnt hier, dass game nie benutzt wird. Für diesen Test ist das in Ordnung.

Denk daran, auch parseGuess anzupassen: Der gültige Bereich hängt jetzt vom Schwierigkeitsgrad ab. Gib der Funktion dafür einen zweiten Parameter in range: ClosedRange<Int>.

Schritt 6: Der Computer spielt 1.000 Spiele

Zum Abschluss verbinden wir alles. Der Computer-Spieler aus Teil 1 wird zu einer Funktion, die ein ganzes Spiel spielt und die Anzahl der Versuche zurückgibt. Dann lassen wir ihn 1.000 Spiele spielen und werten sie aus.

Profi

Aufgabe

  1. Schreib eine Funktion playAutomatically(_ game: Game) -> Int, die das Spiel mit binärer Suche spielt und die Anzahl der Versuche zurückgibt. Die Grenzen nimmst du aus game.difficulty.range. Weil Parameter Konstanten sind, brauchst du in der Funktion eine Kopie: var game = game.
  2. Lass für jeden Schwierigkeitsgrad 1.000 Spiele laufen und sammle die Versuche in einem Array.
  3. Gib für jeden Schwierigkeitsgrad den Durchschnitt und das Maximum aus. Verwende dafür reduce und max() (Lektion 1.11).
LösungErst selbst probieren!
Swift
func playAutomatically(_ game: Game) -> Int {
    var game = game
    var low = game.difficulty.range.lowerBound
    var high = game.difficulty.range.upperBound

    while !game.isOver {
        let guess = (low + high) / 2
        switch game.guess(guess) {
        case .tooLow:
            low = guess + 1
        case .tooHigh:
            high = guess - 1
        case .correct:
            break
        }
    }
    return game.attempts
}

for difficulty in Difficulty.allCases {
    var results: [Int] = []

    for _ in 1...1000 {
        let game = Game(difficulty: difficulty)
        results.append(playAutomatically(game))
    }

    let average = Double(results.reduce(0, +)) / Double(results.count)
    let maximum = results.max() ?? 0

    print("\(difficulty.rawValue): Ø \(average) Versuche, höchstens \(maximum)")
}

Erwartetes Ergebnis

Ungefähr so, die genauen Durchschnittswerte schwanken leicht:

Text
Leicht: Ø 2.9 Versuche, höchstens 4
Mittel: Ø 5.8 Versuche, höchstens 7
Schwer: Ø 9.0 Versuche, höchstens 10

Achte auf das switch über den Hinweis: Weil Hint ein Enum ist, prüft der Compiler, dass alle drei Fälle behandelt werden. Bei .correct gibt es nichts zu tun, deshalb steht dort break. Die Schleife endet trotzdem, weil game.isOver jetzt true ist.

Die Schleife braucht etwas Zeit, weil der Playground jede Zeile protokolliert. Das ist normal.

Schau dir die Zahlen an: Der Bereich wird jeweils zehnmal größer, aber die Anzahl der Versuche steigt nur um etwa drei. Das ist die Stärke der binären Suche.

Was du in diesem Projekt angewendet hast

BausteinWo im ProjektLektion
Variablen, Konstanten, TypenGeheimzahl, Versuche1.2
Bedingungen, switchVergleich, Hinweise1.4
Schleifen, break, continueSpielablauf1.5
ArraysEingaben, Ergebnisse1.6
Optionals, guard letparseGuess1.8, 1.9
Funktionenevaluate, parseGuess, playAutomatically1.10
reduce, maxAuswertung1.11
Structs, mutating, initGame1.12
Werttypenvar game = game1.13
Enums, CaseIterableHint, Difficulty1.14
GenericsClosedRange<Int>1.18

Geschafft

Herzlichen Glückwunsch, du hast Stufe 1 abgeschlossen! Du hast nicht nur die Sprache Swift gelernt, sondern auch erlebt, wie man ein Programm plant, schrittweise baut und verbessert. Genau so wirst du ab jetzt auch Apps entwickeln. In Stufe 2 bekommt dein Code endlich eine Oberfläche.

Bonus-Aufgaben

Fortgeschritten

Bonus 1: Bessere Fehlermeldungen

Ersetze parseGuess durch eine Funktion mit throws, die zwei unterschiedliche Fehler wirft: notANumber und outOfRange(ClosedRange<Int>). Gib in der Spielschleife eine passende Meldung für jeden Fehler aus, z. B. „Bitte eine Zahl zwischen 1 und 100 eingeben.“

Profi

Bonus 2: Gedächtnis

Erweitere Game um ein Array history, in dem alle Tipps mit ihrem Hinweis gespeichert werden. Ergänze eine Methode, die einen Tipp als „sinnlos“ erkennt, wenn er nach den bisherigen Hinweisen gar nicht mehr richtig sein kann. Beispiel: Wer schon weiß, dass 50 zu groß ist, sollte nicht 70 raten.

Profi

Bonus 3: Vergleich der Strategien

Schreib eine zweite Funktion playNaively(_:), die von unten hochzählt wie in Teil 1, und vergleich die Durchschnittswerte beider Strategien über 1.000 Spiele pro Schwierigkeitsgrad.

Zusammenfassung

  • Refactoring verbessert den Code, ohne das Verhalten zu ändern.
  • Ein Enum für Hinweise ist sicherer als Text und lässt sich mit switch vollständig auswerten.
  • Funktionen bündeln Logik an einer Stelle. Eine Struct fasst Daten und Verhalten des Spiels zusammen.
  • Optionals und guard fangen ungültige Eingaben sauber ab.
  • Mit CaseIterable, Schleifen und reduce lassen sich ganze Testreihen automatisch auswerten.

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.