Große Flächen auf einmal – und ein Krebs, der zwickt
Hast du beim Spielen gemerkt, wie mühsam die leeren Felder sind? Ein leeres Feld heißt: Um mich herum ist alles sicher. Trotzdem musst du jeden Nachbarn einzeln anklicken. Heute bringen wir dem Watt bei, das selbst zu tun. Dabei lernst du eine der elegantesten Ideen der Programmierung kennen: eine Funktion, die sich selbst aufruft. Und danach sorgen wir dafür, dass ein Tritt auf einen Krebs das Spiel wirklich beendet.
Bevor wir loslegen
Wir machen da weiter, wo Etappe 3 aufgehört hat. Den kompletten Stand findest du am Ende von Etappe 3.
Die Idee für heute stammt direkt aus dem echten Leben. Stell dir vor, du stehst auf einem Feld ohne Krebs-Nachbarn. Dann weißt du: Alle acht Felder um dich herum sind sicher. Also trittst du auf jedes davon. Und wenn eins davon auch keine Krebs-Nachbarn hat? Dann machst du dort genau dasselbe noch einmal. Und so weiter, bis du überall auf Zahlen stößt. Diese Regel „Mach dasselbe noch mal, nur ein Stück weiter“ heißt beim Programmieren Rekursion.
Eine Funktion, die sich selbst aufruft
Wir schreiben eine neue Funktion aufdecken. Sie deckt ein Feld auf, und
wenn es keine Krebs-Nachbarn hat, ruft sie sich selbst für jeden Nachbarn auf. Sie kommt in
impl Watt, direkt hinter update:
self.felder[i].zustand = Zustand::Aufgedeckt; } } }// Deckt Feld i auf. Hat es keine Krebs-Nachbarn, deckt es auch alle Nachbarn auf. fn aufdecken(&mut self, i: usize) { if self.felder[i].zustand != Zustand::Verdeckt { return; // schon aufgedeckt: nichts zu tun } self.felder[i].zustand = Zustand::Aufgedeckt; if self.felder[i].nachbarn == 0 && !self.felder[i].krebs { for n in nachbarn(i) { self.aufdecken(n); } } }
Von oben nach unten:
&mut selfheißt: Diese Funktion darf das Watt verändern, genau wieupdate.- Die erste Prüfung ist die wichtigste der ganzen Funktion: Ist das Feld schon
aufgedeckt, hören wir sofort auf.
returnheißt „Funktion hier beenden“. - Sonst decken wir das Feld auf.
- Hat es keine Krebs-Nachbarn (und ist selbst kein Krebs), gehen wir alle Nachbarn durch
und rufen für jeden
self.aufdecken(n)auf, also genau die Funktion, in der wir gerade stehen.
cargo run
Rust meldet, dass die neue Funktion aufdecken nie benutzt wird. Klar,
wir rufen sie noch nirgends auf. Das kommt jetzt.Das Watt deckt sich selbst auf
Jetzt lassen wir update nicht mehr selbst aufdecken, sondern die neue
Funktion rufen. Eine einzige Zeile ändert sich:
Message::Aufdecken(i) => {self.felder[i].zustand = Zustand::Aufgedeckt;self.aufdecken(i);}
Mehr ist es nicht. Klickst du auf ein Feld mit Zahl, deckt aufdecken nur dieses
eine Feld auf. Klickst du auf ein leeres Feld, geht die Kettenreaktion los.
cargo run
Klick auf ein Feld, und mit etwas Glück klappt eine ganze Fläche auf einmal auf,
bis zu einem Rand aus Zahlen. So fühlt sich Minesweeper an! Probier ein paarmal, das
Programm neu zu starten: Manchmal ist die Fläche klein, manchmal riesig.Autsch! Das Spiel ist vorbei
Bisher kannst du fröhlich auf Krebse treten und einfach weiterspielen. Das ändern wir.
Das Spiel muss sich dafür merken, ob es noch läuft. Wieder eine Sache mit festen
Möglichkeiten, also wieder ein enum. Es kommt zwischen Feld und
Watt:
zustand: Zustand, }// Läuft das Spiel noch, oder ist es vorbei? #[derive(PartialEq)] enum Spiel { Laeuft, Verloren, }// Das ganze Watt: eine Liste aller Felder.
Rust mag keine Umlaute in Namen, deshalb Laeuft. Das Watt merkt
sich jetzt zusätzlich den Spielstand:
struct Watt { felder: Vec<Feld>,spiel: Spiel,}
Und ein neues Spiel beginnt natürlich mit einem laufenden Spiel:
Watt { felder }Watt { felder, spiel: Spiel::Laeuft }}
Jetzt das Herzstück, die neue Regel in update:
Message::Aufdecken(i) => {self.aufdecken(i);if self.spiel != Spiel::Laeuft { return; // Spiel vorbei: Klicks werden ignoriert } if self.felder[i].krebs { self.spiel = Spiel::Verloren; self.alle_krebse_zeigen(); } else { self.aufdecken(i); }}
In Worten: Ist das Spiel schon vorbei, passiert bei einem Klick gar nichts mehr. Steckt
unter dem angeklickten Feld ein Krebs, ist das Spiel verloren, und wir zeigen alle Krebse.
Sonst decken wir auf wie bisher. Bleibt nur noch alle_krebse_zeigen, direkt
hinter aufdecken:
for n in nachbarn(i) { self.aufdecken(n); } } }// Deckt alle Krebse auf, damit man sieht, wo sie steckten. fn alle_krebse_zeigen(&mut self) { for feld in &mut self.felder { if feld.krebs { feld.zustand = Zustand::Aufgedeckt; } } }
for feld in &mut self.felder geht alle Felder durch, und zwar so, dass wir
sie verändern dürfen. Das &mut heißt: „Leih mir jedes Feld aus, und ich darf
daran etwas ändern.“ Ohne &mut bekämen wir nur zum Anschauen ausgeliehene
Felder. Mehr zum Ausleihen: Buch, Kapitel 45.
cargo run
Tritt absichtlich auf einen Krebs: Sofort werden alle Krebsfelder rot, und danach
reagiert das Watt auf keinen Klick mehr. Um weiterzuspielen, musst du das Programm im Moment
noch neu starten. Einen Knopf für ein neues Spiel bauen wir in Etappe 6.Eine Statuszeile
Zum Schluss soll das Spiel dem Spieler auch sagen, was los ist. Unter die Überschrift kommt eine Statuszeile: Während des Spiels steht dort, wie viele Krebse im Watt stecken, und nach einem Fehltritt ein mitfühlendes „Autsch!“.
let titel = text("WATTLÄUFER").size(22).color(SCHAUM);let status = match self.spiel { Spiel::Laeuft => format!("Krebse im Watt: {}", KREBSE), Spiel::Verloren => String::from("Autsch! Gezwickt."), };⋮let inhalt = column![titel, watt].spacing(14);let inhalt = column![titel, text(status).color(SCHAUM), watt].spacing(14);
Zwei Arten, einen Text zu bauen:
format!("Krebse im Watt: {}", KREBSE)setzt einen Wert in einen Text ein. Die geschweiften Klammern sind der Platzhalter, und dahinter steht, was hinein soll. Das ist dasselbe Prinzip wie beiprintln!.String::from("…")macht aus einem festen Text einenString. Warum der Umweg? Beide Zweige vonmatchmüssen dieselbe Art von Wert liefern.format!liefert immer einenString, also muss der andere Zweig auch einen liefern.
Die Statuszeile landet dann in der Spalte zwischen Überschrift und Watt.
cargo run
Unter der Überschrift steht Krebse im Watt: 10. Tritt auf einen Krebs,
und die Zeile wechselt zu Autsch! Gezwickt.
Der komplette Code nach Etappe 4
Hier ist der vollständige Stand nach Etappe 4. Nimm ihn zum Vergleichen, wenn irgendwo ein Fehler steckt, den du nicht findest, oder als Einstiegspunkt, wenn du erst bei der nächsten Etappe dazustößt.
Cargo.toml und src/main.rs anzeigen
Cargo.toml
[package]
name = "wattlaeufer"
version = "0.1.0"
edition = "2024"
[dependencies]
iced = "0.14"
rand = "0.10"
src/main.rs
use iced::widget::{column, container, mouse_area, row, text};
use iced::{mouse, Background, Border, Color, Element, Fill, Font, Theme};
// Die Farben des Watts
const MEER: Color = Color::from_rgb8(0x1f, 0x4e, 0x6b);
const SCHAUM: Color = Color::from_rgb8(0xf4, 0xf1, 0xe8);
const SAND: Color = Color::from_rgb8(0xe8, 0xc9, 0x8f);
const SAND_KANTE: Color = Color::from_rgb8(0xc9, 0xa4, 0x62);
const NASS: Color = Color::from_rgb8(0xb9, 0xa8, 0x86);
const ROT: Color = Color::from_rgb8(0xd9, 0x4a, 0x2b);
// So groß ist ein Feld, in Pixeln
const FELD: f32 = 44.0;
// So groß ist das Watt
const BREITE: usize = 9;
const HOEHE: usize = 9;
// So viele Krebse verstecken sich im Watt
const KREBSE: usize = 10;
fn main() -> iced::Result {
iced::application(Watt::neu, Watt::update, Watt::view)
.title("Wattläufer")
.default_font(Font::MONOSPACE)
.window_size((460.0, 560.0))
.run()
}
// Ein Feld ist entweder noch verdeckt oder schon aufgedeckt.
#[derive(Clone, Copy, PartialEq)]
enum Zustand {
Verdeckt,
Aufgedeckt,
}
// Alles, was wir über ein einzelnes Feld wissen.
#[derive(Clone, Copy)]
struct Feld {
krebs: bool,
nachbarn: u8,
zustand: Zustand,
}
// Läuft das Spiel noch, oder ist es vorbei?
#[derive(PartialEq)]
enum Spiel {
Laeuft,
Verloren,
}
// Das ganze Watt: eine Liste aller Felder.
struct Watt {
felder: Vec<Feld>,
spiel: Spiel,
}
#[derive(Debug, Clone)]
enum Message {
Aufdecken(usize),
}
impl Watt {
// Legt ein neues Watt an, in dem alle Felder verdeckt sind.
fn neu() -> Self {
let leer = Feld {
krebs: false,
nachbarn: 0,
zustand: Zustand::Verdeckt,
};
let mut felder = vec![leer; BREITE * HOEHE];
// Zehn verschiedene Feldnummern auslosen und dort Krebse verstecken
let mut rng = rand::rng();
for i in rand::seq::index::sample(&mut rng, BREITE * HOEHE, KREBSE) {
felder[i].krebs = true;
}
// Für jedes Feld zählen, wie viele Krebse in den Nachbarfeldern stecken
for i in 0..felder.len() {
let mut anzahl = 0;
for n in nachbarn(i) {
if felder[n].krebs {
anzahl += 1;
}
}
felder[i].nachbarn = anzahl;
}
Watt { felder, spiel: Spiel::Laeuft }
}
fn update(&mut self, message: Message) {
match message {
Message::Aufdecken(i) => {
if self.spiel != Spiel::Laeuft {
return; // Spiel vorbei: Klicks werden ignoriert
}
if self.felder[i].krebs {
self.spiel = Spiel::Verloren;
self.alle_krebse_zeigen();
} else {
self.aufdecken(i);
}
}
}
}
// Deckt Feld i auf. Hat es keine Krebs-Nachbarn, deckt es auch alle Nachbarn auf.
fn aufdecken(&mut self, i: usize) {
if self.felder[i].zustand != Zustand::Verdeckt {
return; // schon aufgedeckt: nichts zu tun
}
self.felder[i].zustand = Zustand::Aufgedeckt;
if self.felder[i].nachbarn == 0 && !self.felder[i].krebs {
for n in nachbarn(i) {
self.aufdecken(n);
}
}
}
// Deckt alle Krebse auf, damit man sieht, wo sie steckten.
fn alle_krebse_zeigen(&mut self) {
for feld in &mut self.felder {
if feld.krebs {
feld.zustand = Zustand::Aufgedeckt;
}
}
}
fn view(&self) -> Element<'_, Message> {
let titel = text("WATTLÄUFER").size(22).color(SCHAUM);
let status = match self.spiel {
Spiel::Laeuft => format!("Krebse im Watt: {}", KREBSE),
Spiel::Verloren => String::from("Autsch! Gezwickt."),
};
let mut watt = column![].spacing(3);
for y in 0..HOEHE {
let mut reihe = row![].spacing(3);
for x in 0..BREITE {
let i = y * BREITE + x;
reihe = reihe.push(self.feld_ansicht(i));
}
watt = watt.push(reihe);
}
let inhalt = column![titel, text(status).color(SCHAUM), watt].spacing(14);
container(inhalt)
.padding(20)
.width(Fill)
.height(Fill)
.style(|_theme: &Theme| container::Style {
background: Some(Background::Color(MEER)),
..container::Style::default()
})
.into()
}
// Zeichnet das Feld Nummer i, je nachdem, in welchem Zustand es ist.
fn feld_ansicht(&self, i: usize) -> Element<'_, Message> {
let feld = self.felder[i];
let verdeckt = feld.zustand == Zustand::Verdeckt;
let grund = if verdeckt {
SAND
} else if feld.krebs {
ROT
} else {
NASS
};
let inhalt = if !verdeckt && !feld.krebs && feld.nachbarn > 0 {
text(feld.nachbarn).size(20).color(zahlfarbe(feld.nachbarn))
} else {
text("")
};
let kachel = container(inhalt)
.center(FELD)
.style(move |_theme: &Theme| container::Style {
background: Some(Background::Color(grund)),
border: Border {
color: SAND_KANTE,
width: if verdeckt { 2.0 } else { 0.0 },
radius: 6.0.into(),
},
..container::Style::default()
});
mouse_area(kachel)
.on_press(Message::Aufdecken(i))
.interaction(mouse::Interaction::Pointer)
.into()
}
}
// Liefert die Nummern aller Nachbarfelder von Feld i (höchstens acht).
fn nachbarn(i: usize) -> Vec<usize> {
let x = (i % BREITE) as i32;
let y = (i / BREITE) as i32;
let mut liste = Vec::new();
for dy in -1..=1 {
for dx in -1..=1 {
if dx == 0 && dy == 0 {
continue; // das Feld selbst ist nicht sein eigener Nachbar
}
let nx = x + dx;
let ny = y + dy;
if nx >= 0 && nx < BREITE as i32 && ny >= 0 && ny < HOEHE as i32 {
liste.push(ny as usize * BREITE + nx as usize);
}
}
}
liste
}
// Jede Zahl bekommt ihre eigene Farbe, damit man sie schneller erkennt.
fn zahlfarbe(anzahl: u8) -> Color {
match anzahl {
1 => MEER,
2 => Color::from_rgb8(0x2e, 0x7d, 0x4f),
3 => Color::from_rgb8(0xc2, 0x3b, 0x22),
_ => Color::from_rgb8(0x5a, 0x2a, 0x6e),
}
}
return
beendet eine Funktion vorzeitig. Mit einem weiteren enum merkt sich das Spiel,
ob es noch läuft. for … in &mut liste geht eine Liste durch und darf sie
dabei verändern, und format! baut Texte mit eingesetzten Werten.Zum Ausprobieren
Die Übungen sind freiwillig, aber sie helfen enorm. Mach nach jeder Übung einen Probelauf, und stell danach den alten Zustand wieder her, damit die nächste Etappe bei dir genauso weitergeht wie bei allen anderen.
- Ohne Bremse: Was würde passieren, wenn du in
aufdeckendie erste Prüfung (if … != Zustand::Verdeckt { return; }) weglässt? Überleg es dir erst. Wenn du es ausprobierst, sei auf einen Absturz gefasst. - Wie viele Felder? Erweitere die Statuszeile so, dass beim Verlieren zusätzlich angezeigt wird, wie viele Felder du bis dahin aufgedeckt hattest. Tipp: Zähl vor dem Aufdecken der Krebse.
- Gnädiges Watt: Ändere die Regel so, dass Krebse beim Verlieren nicht aufgedeckt werden, sondern nur das getroffene Feld. Welche Zeile musst du dafür ändern?
Lösungen anzeigen
1. Die Funktion würde sich endlos gegenseitig aufrufen: A deckt B auf, B deckt A auf, und
so weiter. Jeder Aufruf belegt ein Stück Speicher auf dem sogenannten Stack, und
irgendwann ist der voll. Rust bricht das Programm dann mit einer Meldung wie
thread 'main' has overflowed its stack ab. Genau das verhindert die
Abbruchbedingung.
2. Ein neues Feld in Watt wäre eine Möglichkeit, einfacher ist es in
view zu zählen: let aufgedeckt = self.felder.iter().filter(|f|
f.zustand == Zustand::Aufgedeckt && !f.krebs).count(); und dann
format!("Autsch! Nach {} Feldern gezwickt.", aufgedeckt). Weil nach dem
Verlieren nur Krebse zusätzlich aufgedeckt werden, filtern wir sie mit
!f.krebs einfach heraus.
3. Statt self.alle_krebse_zeigen(); schreibst du
self.felder[i].zustand = Zustand::Aufgedeckt;. Dann wird nur das eine Feld
rot. Ehrlich gesagt ist die Originalregel aber netter: Wer verliert, möchte sehen, wo die
anderen Krebse steckten.
Etappe 4 geschafft 🌊
Dein Watt deckt ganze Flächen auf einmal auf, und ein Krebs beendet das Spiel. Damit ist aus dem Klick-Spielzeug ein echtes Spiel geworden. In der nächsten Etappe fehlen nur noch zwei Dinge: Warnfähnchen per Rechtsklick und die Möglichkeit, zu gewinnen.
Hat dir das Mitbauen Spaß gemacht? Ein Kaffee hilft, dass die nächste Werkstatt ein Stück schneller fertig wird.
☕ Kaffee spendieren