Fähnchen, Sieg und Niederlage
Unser Spiel hat noch eine große Lücke: Man kann nur verlieren. Heute bekommt der Wattläufer rote Warnfähnchen, die man per Rechtsklick dort aufstellt, wo man einen Krebs vermutet. Dabei malen wir unsere erste eigene Grafik, finden gemeinsam einen echten Fehler und reparieren ihn. Und am Ende kann man endlich gewinnen.
Bevor wir loslegen
Wir machen da weiter, wo Etappe 4 aufgehört hat. Den kompletten Stand findest du am Ende von Etappe 4.
Ein Fähnchen ist ein dritter Zustand für ein Feld: nicht verdeckt, nicht aufgedeckt,
sondern markiert. Wir erweitern also unser enum Zustand. Das Schöne an
Rust: Sobald ein enum eine neue Möglichkeit bekommt, zeigt dir der Compiler jede
Stelle, an der du sie noch berücksichtigen musst. Du kannst sie also gar nicht
vergessen.
Ein dritter Zustand
Zuerst bekommt das Feld seinen neuen Zustand:
enum Zustand { Verdeckt,Markiert,Aufgedeckt, }
Dann eine neue Briefsorte. Ein Rechtsklick auf Feld 12 wird zum Brief
Markieren(12):
enum Message { Aufdecken(usize),Markieren(usize),}
Jetzt würde Rust sich beschweren, denn update kennt den neuen Brief noch nicht.
Genau das meinte ich vorhin: match muss alle Möglichkeiten abdecken. Also bekommt
update einen zweiten Zweig:
self.aufdecken(i); } }Message::Markieren(i) => { if self.spiel != Spiel::Laeuft { return; } let feld = &mut self.felder[i]; feld.zustand = match feld.zustand { Zustand::Verdeckt => Zustand::Markiert, Zustand::Markiert => Zustand::Verdeckt, Zustand::Aufgedeckt => Zustand::Aufgedeckt, }; }} }
Ein Rechtsklick wirkt wie ein Lichtschalter: Verdeckt wird zu markiert, markiert wird
wieder zu verdeckt. Auf aufgedeckte Felder kann man kein Fähnchen stellen, da bleibt alles,
wie es ist. Neu ist let feld = &mut self.felder[i];: Wir leihen uns das Feld
zum Verändern aus und geben ihm einen kurzen Namen, damit wir nicht dreimal
self.felder[i] schreiben müssen.
Bleiben zwei kleine Änderungen in feld_ansicht. Ein markiertes Feld ist ja noch
nicht aufgedeckt, es soll also weiter wie trockener Sand aussehen. Deshalb fragen wir jetzt
„nicht aufgedeckt“ statt „verdeckt“. Und der Mausbereich lauscht zusätzlich auf die rechte
Maustaste:
let feld = self.felder[i];let verdeckt = feld.zustand == Zustand::Verdeckt;let verdeckt = feld.zustand != Zustand::Aufgedeckt;let grund = if verdeckt {⋮mouse_area(kachel) .on_press(Message::Aufdecken(i)).on_right_press(Message::Markieren(i)).interaction(mouse::Interaction::Pointer)
cargo run
Zu sehen ist noch nichts, denn wir zeichnen noch kein Fähnchen. Aber probier mal:
Rechtsklick auf ein Feld, dann Linksklick auf dasselbe Feld. Es passiert nichts, das Feld
bleibt zu. Das unsichtbare Fähnchen steht also schon. Zumindest meistens, dazu gleich mehr.Ein Fähnchen malen
Jetzt machen wir das Fähnchen sichtbar. Wir malen es als SVG. Das ist ein Bildformat, das nicht aus Pixeln besteht, sondern aus einer Beschreibung wie „ein Strich hier, ein Dreieck dort“. Dadurch bleibt es in jeder Größe gestochen scharf. iced kann SVGs anzeigen, aber diese Fähigkeit ist ein Extra, das wir erst einschalten müssen. Solche Extras heißen bei Crates Features:
cargo add iced@0.14 --features svg
Danach sieht die iced-Zeile in der Cargo.toml so aus:
[package]
name = "wattlaeufer"
version = "0.1.0"
edition = "2024"
[dependencies]
iced = { version = "0.14", features = ["svg"] }
rand = "0.10"
Und in unserer Datei holen wir das Widget svg dazu:
use iced::widget::{column, container, mouse_area, row, text};use iced::widget::{column, container, mouse_area, row, svg, text};
Jetzt das Fähnchen selbst. Die Grafik schreiben wir direkt in unser Programm, als
Konstante hinter KREBSE:
const KREBSE: usize = 10;// Ein kleines Warnfähnchen als SVG-Grafik const FAHNE: &[u8] = br##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> <rect x="6" y="3" width="2" height="18" fill="#3b2a1a"/> <path d="M8 4 L19 8 L8 12 Z" fill="#d94a2b"/> <ellipse cx="7" cy="21" rx="5" ry="1.5" fill="#8a6a3a"/> </svg>"##;
Das sieht wilder aus, als es ist. Die vier Zeilen in der Mitte sind die Zeichnung: ein
dunkler Fahnenstab (rect, ein Rechteck), ein rotes Dreieck als Fahne
(path, eine Linie von Punkt zu Punkt) und eine kleine Sandmulde darunter
(ellipse). Rund herum steht br##"…"##:
- Das
bheißt: Das Programm bekommt keinen Text, sondern die rohen Bytes. Genau so will iced Bilddaten haben, deshalb ist der Typ&[u8], eine Reihe von Bytes. - Das
rund die Rauten##machen daraus einen Rohtext: Alles zwischen"##und##"wird genau so übernommen, auch die vielen Anführungszeichen und das#in den Farbangaben. Ohne diesen Trick würde Rust beim ersten"denken, der Text sei zu Ende.
Zum Schluss zeigen wir das Fähnchen auf markierten Feldern an:
let inhalt = if !verdeckt && !feld.krebs && feld.nachbarn > 0 { text(feld.nachbarn).size(20).color(zahlfarbe(feld.nachbarn)) } else { text("") };let inhalt: Element<'_, Message> = if feld.zustand == Zustand::Markiert { svg(svg::Handle::from_memory(FAHNE)).width(26).height(26).into() } else if !verdeckt && !feld.krebs && feld.nachbarn > 0 { text(feld.nachbarn).size(20).color(zahlfarbe(feld.nachbarn)).into() } else { text("").into() };
Hier steckt ein kleines Rust-Rätsel. Bisher lieferten beide Zweige des if
einen Text. Jetzt liefert einer ein Bild und die anderen einen Text, und das sind
verschiedene Typen. Rust verlangt aber, dass alle Zweige dasselbe liefern. Die Lösung: Wir
verpacken jeden Zweig mit .into() in ein Element, also in die
allgemeine Kiste für „irgendwas Anzeigbares“. Damit Rust weiß, in welche Kiste, steht der
Typ ausdrücklich hinter dem Namen: let inhalt: Element<'_, Message>.
`if` and `else` have incompatible types
bekommst, fehlt irgendwo ein .into() oder die Typangabe hinter
inhalt. Und wenn Rust meldet, dass es svg nicht findet, ist das
Feature nicht eingeschaltet: Schau in die Cargo.toml. Der erste Start dauert
wieder etwas, weil iced mit dem neuen Feature neu übersetzt wird.cargo run
Rechtsklick auf ein Feld: Ein rotes Fähnchen steckt im Sand. Nochmal
Rechtsklick, und es ist wieder weg.Ein Fehler im Watt
Erinnerst du dich an das „meistens“ aus Schritt 1? Probier Folgendes: Stell ein Fähnchen auf ein Feld und klick es dann mit der linken Maustaste an. Meistens passiert nichts. Aber wenn unter dem Fähnchen zufällig ein Krebs steckt, ist das Spiel verloren! Das ist ein echter Fehler, ein Bug. Ein Fähnchen soll ja gerade davor schützen.
Woran liegt es? Schauen wir in update. Dort prüfen wir, ob unter dem Feld ein
Krebs ist, bevor wir irgendetwas anderes fragen. Ob das Feld markiert ist, spielt
überhaupt keine Rolle. Nur bei sicheren Feldern landen wir in aufdecken, und das
prüft den Zustand, deshalb passiert dort nichts. Die Reparatur ist eine einzige Prüfung, an
der richtigen Stelle:
if self.spiel != Spiel::Laeuft { return; // Spiel vorbei: Klicks werden ignoriert }if self.felder[i].zustand != Zustand::Verdeckt { return; // Fähnchen oder schon offen: nicht aufdecken }if self.felder[i].krebs {
Jetzt gilt: Nur ein verdecktes Feld lässt sich aufdecken. Markierte Felder sind geschützt, und ein Klick auf ein schon aufgedecktes Feld tut auch nichts, was ja sowieso richtig ist.
cargo run
Stell Fähnchen auf ein paar Felder und klick sie mit links an: Nichts passiert,
egal was darunter steckt. Die Fähnchen schützen jetzt zuverlässig.Endlich gewinnen
Wann hat man gewonnen? Nicht, wenn man alle Krebse markiert hat, denn Fähnchen kann man auch falsch setzen. Gewonnen hat, wer jedes Feld ohne Krebs aufgedeckt hat. Zuerst bekommt das Spiel einen dritten Ausgang:
enum Spiel { Laeuft,Gewonnen,Verloren, }
Nach jedem erfolgreichen Aufdecken fragen wir, ob damit alles geschafft ist:
} else {self.aufdecken(i);self.aufdecken(i); if self.alles_sicher_aufgedeckt() { self.spiel = Spiel::Gewonnen; }}
Die Frage selbst beantwortet eine neue Funktion hinter alle_krebse_zeigen:
if feld.krebs { feld.zustand = Zustand::Aufgedeckt; } } }// Gewonnen hat, wer jedes Feld ohne Krebs aufgedeckt hat. fn alles_sicher_aufgedeckt(&self) -> bool { self.felder .iter() .all(|feld| feld.krebs || feld.zustand == Zustand::Aufgedeckt) }
Das ist eine neue, sehr rustige Art, mit Listen zu arbeiten. Statt einer
for-Schleife sagen wir: .iter() „geh die Felder durch“ und
.all(…) „stimmt das hier für alle?“. In den Klammern steht eine Closure,
die für jedes Feld prüft: Ist es ein Krebs oder (||) schon aufgedeckt?
Nur wenn das für jedes einzelne Feld stimmt, liefert all true.
Man kann es fast laut vorlesen, und genau das ist der Vorteil.
Zum Schluss passen wir die Statuszeile an. Sie bekommt den Siegestext, und der Krebszähler zählt jetzt rückwärts, sobald Fähnchen stehen:
let status = match self.spiel { Spiel::Laeuft => format!("Krebse im Watt: {}", KREBSE),let fahnen = self .felder .iter() .filter(|feld| feld.zustand == Zustand::Markiert) .count(); let status = match self.spiel { Spiel::Laeuft => format!("Krebse im Watt: {}", KREBSE as i32 - fahnen as i32), Spiel::Gewonnen => String::from("Geschafft! Trockene Füße."),Spiel::Verloren => String::from("Autsch! Gezwickt."), };
Wieder dieselbe Idee: .iter() geht die Felder durch, .filter(…)
behält nur die markierten, und .count() zählt, wie viele übrig sind. Beim
Abziehen wandeln wir beide Zahlen in i32 um. Wer mehr Fähnchen setzt, als es
Krebse gibt, sieht so eine negative Zahl, und das ist sogar ein hilfreicher Hinweis, dass
irgendwo eines zu viel steht.
Spiel die Variante Gewonnen hat, verlangt Rust
auch in der Statuszeile einen Zweig dafür. Fehlt er, meldet der Compiler
non-exhaustive patterns: `Spiel::Gewonnen` not covered. Das ist genau die
Hilfe, von der am Anfang dieser Etappe die Rede war.cargo run
Spiel eine Runde, diesmal richtig: Setz Fähnchen, wo du Krebse vermutest, und
deck alle anderen Felder auf. Der Zähler läuft mit jedem Fähnchen herunter. Und wenn das
letzte sichere Feld offen ist, steht da: Geschafft! Trockene Füße.
Der komplette Code nach Etappe 5
Hier ist der vollständige Stand nach Etappe 5. 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 = { version = "0.14", features = ["svg"] }
rand = "0.10"
src/main.rs
use iced::widget::{column, container, mouse_area, row, svg, 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;
// Ein kleines Warnfähnchen als SVG-Grafik
const FAHNE: &[u8] = br##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<rect x="6" y="3" width="2" height="18" fill="#3b2a1a"/>
<path d="M8 4 L19 8 L8 12 Z" fill="#d94a2b"/>
<ellipse cx="7" cy="21" rx="5" ry="1.5" fill="#8a6a3a"/>
</svg>"##;
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,
Markiert,
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,
Gewonnen,
Verloren,
}
// Das ganze Watt: eine Liste aller Felder.
struct Watt {
felder: Vec<Feld>,
spiel: Spiel,
}
#[derive(Debug, Clone)]
enum Message {
Aufdecken(usize),
Markieren(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].zustand != Zustand::Verdeckt {
return; // Fähnchen oder schon offen: nicht aufdecken
}
if self.felder[i].krebs {
self.spiel = Spiel::Verloren;
self.alle_krebse_zeigen();
} else {
self.aufdecken(i);
if self.alles_sicher_aufgedeckt() {
self.spiel = Spiel::Gewonnen;
}
}
}
Message::Markieren(i) => {
if self.spiel != Spiel::Laeuft {
return;
}
let feld = &mut self.felder[i];
feld.zustand = match feld.zustand {
Zustand::Verdeckt => Zustand::Markiert,
Zustand::Markiert => Zustand::Verdeckt,
Zustand::Aufgedeckt => 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);
}
}
}
// 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;
}
}
}
// Gewonnen hat, wer jedes Feld ohne Krebs aufgedeckt hat.
fn alles_sicher_aufgedeckt(&self) -> bool {
self.felder
.iter()
.all(|feld| feld.krebs || feld.zustand == Zustand::Aufgedeckt)
}
fn view(&self) -> Element<'_, Message> {
let titel = text("WATTLÄUFER").size(22).color(SCHAUM);
let fahnen = self
.felder
.iter()
.filter(|feld| feld.zustand == Zustand::Markiert)
.count();
let status = match self.spiel {
Spiel::Laeuft => format!("Krebse im Watt: {}", KREBSE as i32 - fahnen as i32),
Spiel::Gewonnen => String::from("Geschafft! Trockene Füße."),
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::Aufgedeckt;
let grund = if verdeckt {
SAND
} else if feld.krebs {
ROT
} else {
NASS
};
let inhalt: Element<'_, Message> = if feld.zustand == Zustand::Markiert {
svg(svg::Handle::from_memory(FAHNE)).width(26).height(26).into()
} else if !verdeckt && !feld.krebs && feld.nachbarn > 0 {
text(feld.nachbarn).size(20).color(zahlfarbe(feld.nachbarn)).into()
} else {
text("").into()
};
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))
.on_right_press(Message::Markieren(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),
}
}
enum zeigt dir über
den Compiler jede Stelle, an der er noch fehlt. mouse_area kann auf beide
Maustasten hören. Crates haben Features, die man mit
--features einschaltet, zum Beispiel svg bei iced. Rohtexte wie
br##"…"## übernehmen alles wörtlich. Verschiedene Widgets verpackt
.into() in dasselbe Element. Und mit .iter(),
.all(), .filter() und .count() stellt man einer Liste
Fragen, ohne eine Schleife zu schreiben.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.
- Ehrlicher Zähler: Ändere die Statuszeile so, dass sie nie unter 0
fällt. Tipp: Es gibt die Methode
.max(0). - Grüne Fahne: Ändere in
FAHNEdie Farbe#d94a2bauf#2e7d4f. Was ändert sich, und warum musstest du dafür keinen Rust-Code anfassen? - Siegerehrung: Beim Gewinnen bleiben die Krebse unter dem Sand. Sorg dafür, dass nach einem Sieg auf allen Krebsfeldern automatisch ein Fähnchen steht.
Lösungen anzeigen
1. format!("Krebse im Watt: {}", (KREBSE as i32 - fahnen as i32).max(0)).
max(0) liefert die größere von zwei Zahlen, also nie weniger als 0.
2. Das Fähnchen wird grün. Die Farbe steht in der SVG-Zeichnung selbst, und die ist für Rust nur eine Reihe von Bytes, die an iced weitergereicht wird. Was in der Zeichnung steht, interessiert den Compiler nicht.
3. Direkt nach self.spiel = Spiel::Gewonnen; eine Schleife:
for feld in &mut self.felder { if feld.krebs { feld.zustand =
Zustand::Markiert; } }. Das ist fast dieselbe Schleife wie in
alle_krebse_zeigen, nur mit einem anderen Zustand.
Etappe 5 geschafft 🚩
Der Wattläufer ist jetzt ein vollständiges Spiel: Man kann Fähnchen setzen, verlieren und gewinnen, und nebenbei hast du einen echten Bug gefunden und repariert. In der letzten Etappe polieren wir: ein Knopf für ein neues Spiel, ein sicherer erster Schritt, und Ferris zieht ins Watt.
Hat dir das Mitbauen Spaß gemacht? Ein Kaffee hilft, dass die nächste Werkstatt ein Stück schneller fertig wird.
☕ Kaffee spendieren