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:
- Der Vergleich „zu klein / zu groß / richtig“ steht mehrfach im Code.
- Alles steckt in einem langen Block und lässt sich nicht wiederverwenden.
- 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).
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!
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
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!
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).
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 beifalsebeginnt, - einer
mutating-Methodeguess(_:) -> Hint, die den Versuch zählt, das Ergebnis mitevaluateermittelt, bei.correctdas Spiel beendet und den Hinweis zurückgibt.
LösungErst selbst probieren!
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
initmit Standardwert erlaubt beides:Game()für ein echtes Spiel mit Zufallszahl undGame(secret: 37)für vorhersagbare Tests. hint == .correctfunktioniert, weil Enums ohne Associated Values automatisch vergleichbar sind.guessmussmutatingsein, weil esattemptsundisOververändert. Undgamemuss mitvarangelegt 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“.
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 obenimport Foundationoderimport UIKit), nilzurückgibt, wenn der Text keine Zahl ist,nilzurü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!
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:
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
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!
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.
Aufgabe
- 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 ausgame.difficulty.range. Weil Parameter Konstanten sind, brauchst du in der Funktion eine Kopie:var game = game. - Lass für jeden Schwierigkeitsgrad 1.000 Spiele laufen und sammle die Versuche in einem Array.
- Gib für jeden Schwierigkeitsgrad den Durchschnitt und das Maximum aus. Verwende dafür
reduceundmax()(Lektion 1.11).
LösungErst selbst probieren!
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:
Leicht: Ø 2.9 Versuche, höchstens 4
Mittel: Ø 5.8 Versuche, höchstens 7
Schwer: Ø 9.0 Versuche, höchstens 10Achte 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
| Baustein | Wo im Projekt | Lektion |
|---|---|---|
| Variablen, Konstanten, Typen | Geheimzahl, Versuche | 1.2 |
Bedingungen, switch | Vergleich, Hinweise | 1.4 |
Schleifen, break, continue | Spielablauf | 1.5 |
| Arrays | Eingaben, Ergebnisse | 1.6 |
Optionals, guard let | parseGuess | 1.8, 1.9 |
| Funktionen | evaluate, parseGuess, playAutomatically | 1.10 |
reduce, max | Auswertung | 1.11 |
Structs, mutating, init | Game | 1.12 |
| Werttypen | var game = game | 1.13 |
Enums, CaseIterable | Hint, Difficulty | 1.14 |
| Generics | ClosedRange<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
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.“
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.
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
switchvollständig auswerten. - Funktionen bündeln Logik an einer Stelle. Eine Struct fasst Daten und Verhalten des Spiels zusammen.
- Optionals und
guardfangen ungültige Eingaben sauber ab. - Mit
CaseIterable, Schleifen undreducelassen sich ganze Testreihen automatisch auswerten.