Rust-Werkstatt
Werkstatt · Wattläufer · Etappe 2 von 6

Schritte ins Watt – das Spiel bekommt ein Gedächtnis

Unser Watt sieht schon gut aus, aber es ist nur ein Bild. Heute bringen wir Leben hinein: Das Spiel soll sich für jedes einzelne Feld merken, ob es noch verdeckt oder schon aufgedeckt ist. Und wenn du ein Feld anklickst, soll es sich aufdecken. Dafür lernst du die wichtigste Idee von iced richtig kennen: wie ein Klick zu einer Nachricht wird und die Nachricht zu einer Veränderung.

4 Schritte · ca. 35 Minuten · baut auf Etappe 1 auf

Das Watt mit einigen aufgedeckten Feldern in dunklerem, nassem Sand
So sieht der Wattläufer am Ende dieser Etappe aus.
Vorab

Bevor wir loslegen

Wir machen genau da weiter, wo Etappe 1 aufgehört hat. Falls du neu dazukommst oder dein Stand nicht mehr sicher ist: Am Ende von Etappe 1 findest du den kompletten Code. Kopier ihn in dein Projekt, dann kann es losgehen.

Heute geht es um drei Begriffe, die in jedem iced-Programm vorkommen. Wir hatten sie in Etappe 1 schon kurz, jetzt benutzen wir sie wirklich:

  • Der Zustand ist alles, was sich das Programm merkt. Bei uns: welche Felder schon aufgedeckt sind.
  • Eine Nachricht (Message) beschreibt, was passiert ist, zum Beispiel „Feld 12 wurde angeklickt“.
  • update nimmt die Nachricht und ändert den Zustand. view schaut sich den Zustand an und zeichnet den Bildschirm neu.

Das ist ein Kreislauf: Klick → Nachricht → update ändert den Zustand → view zeichnet neu → du siehst das Ergebnis und klickst wieder. Genau diesen Kreislauf bauen wir heute.

Schritt 1

Das Gedächtnis

Bisher ist struct Watt leer. Unser Spiel weiß also nichts, außer dass es existiert. Jetzt soll es sich für jedes Feld merken, in welchem Zustand es ist. Ein Feld kann (vorerst) genau zwei Zustände haben: verdeckt oder aufgedeckt. Wenn etwas genau eine von mehreren festen Möglichkeiten sein kann, nimmt man in Rust ein enum.

Ersetze das alte struct Watt samt der Zeile darüber durch diesen Block:

src/main.rsüber enum Message, anstelle des alten struct Watt
#[derive(Default)]
struct Watt {}// 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 {
    zustand: Zustand,
}

// Das ganze Watt: eine Liste aller Felder.
struct Watt {
    felder: Vec<Feld>,
}#[derive(Debug, Clone)]
enum Message {}

Drei neue Bausteine, von klein nach groß:

  • enum Zustand zählt die Möglichkeiten auf: Verdeckt oder Aufgedeckt. Eine dritte gibt es nicht, und darauf kann sich Rust verlassen. Mehr zu Enums: Buch, Kapitel 10.
  • struct Feld beschreibt ein einzelnes Feld. Im Moment steht darin nur sein Zustand, in Etappe 3 kommt noch dazu, ob ein Krebs darunter steckt.
  • struct Watt hat jetzt eine Liste aller Felder. Vec<Feld> heißt „eine Liste von Feldern“. Ein Vec ist wie eine Reihe nummerierter Schubladen, und in jeder liegt ein Feld.

Die Zeilen mit #[derive(…)] geben den Typen ein paar Fähigkeiten mit, die Rust automatisch für uns erzeugt. Clone und Copy bedeuten: Man darf ein Feld einfach kopieren. PartialEq bedeutet: Man darf zwei Zustände mit == vergleichen. Das brauchen wir gleich.

Weil Watt jetzt Felder enthält, kann Rust es nicht mehr von allein mit Default erzeugen. Deshalb schreiben wir eine eigene Funktion neu, die ein frisches Watt anlegt. Sie kommt ganz oben in impl Watt:

src/main.rsin impl Watt, als erste Funktion
impl Watt {    // Legt ein neues Watt an, in dem alle Felder verdeckt sind.
    fn neu() -> Self {
        let felder = vec![Feld { zustand: Zustand::Verdeckt }; BREITE * HOEHE];
        Watt { felder }
    }    fn update(&mut self, message: Message) {

vec![Feld { … }; BREITE * HOEHE] ist eine Abkürzung für: „Mach eine Liste mit 81 Feldern, und alle sehen am Anfang so aus.“ Der Strichpunkt in den eckigen Klammern trennt das Muster von der Anzahl. Self ist ein Kurzname für den Typ, in dessen impl wir gerade stehen, hier also Watt.

Zum Schluss sagen wir iced, dass es für den Start ab jetzt unsere neue Funktion benutzen soll:

src/main.rsin fn main()
    iced::application(Watt::default, Watt::update, Watt::view)    iced::application(Watt::neu, Watt::update, Watt::view)        .title("Wattläufer")
Probelauf Speichern, dann im Terminal:
cargo run
Das Fenster sieht genauso aus wie vorher, denn am Aussehen haben wir noch nichts geändert. Dafür meldet Rust drei gelbe Warnungen: Die Felder felder und zustand werden nie gelesen, und die Variante Aufgedeckt wird nie benutzt. Das stimmt alles, und genau das ändern wir in den nächsten zwei Schritten. Warnungen sind keine Fehler, das Programm läuft trotzdem.
Schritt 2

Jedes Feld zeigt seinen Zustand

Unsere alte Ausstechform sandfeld() macht alle Felder gleich. Jetzt soll jedes Feld so aussehen, wie es in unserem Gedächtnis steht: verdeckte Felder als trockener Sand mit Rand, aufgedeckte Felder als dunklerer, nasser Sand ohne Rand. Dafür brauchen wir eine neue Farbe:

src/main.rsbei den Farben
const SAND_KANTE: Color = Color::from_rgb8(0xc9, 0xa4, 0x62);const NASS: Color = Color::from_rgb8(0xb9, 0xa8, 0x86);

Die alte Funktion sandfeld hat ausgedient. Lösch sie ganz unten in der Datei:

src/main.rsganz am Ende der Datei: die alte Funktion sandfeld löschen
    }
}// Baut ein einzelnes Sandfeld.
fn sandfeld() -> Element<'static, Message> {
    container(text(""))
        .width(FELD)
        .height(FELD)
        .style(|_theme: &Theme| container::Style {
            background: Some(Background::Color(SAND)),
            border: Border {
                color: SAND_KANTE,
                width: 2.0,
                radius: 6.0.into(),
            },
            ..container::Style::default()
        })
        .into()
}

Stattdessen bekommt impl Watt eine neue Funktion. Sie steht jetzt innerhalb von impl Watt, denn sie muss in unser Gedächtnis schauen können. Schreib sie direkt hinter view, vor die letzte geschweifte Klammer:

src/main.rsin impl Watt, hinter der Funktion view
                ..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 { NASS };

        container(text(""))
            .width(FELD)
            .height(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()
            })
            .into()
    }}

Neu ist hier vor allem der Anfang:

  • &self in der ersten Zeile heißt: Die Funktion darf in unser Watt hineinschauen (aber nichts verändern). Und i ist die Nummer des Feldes, das gezeichnet werden soll.
  • self.felder[i] holt das Feld mit der Nummer i aus der Liste, also den Inhalt von Schublade Nummer i.
  • feld.zustand == Zustand::Verdeckt vergleicht, ob das Feld verdeckt ist. Das Ergebnis ist true oder false und landet in verdeckt.
  • if verdeckt { SAND } else { NASS } wählt die Farbe. In Rust liefert ein if direkt einen Wert, das ist sehr praktisch.
  • Das move vor der Closure bei .style(…) ist wichtig: Es packt grund und verdeckt in die Closure ein, damit sie diese Werte auch später noch kennt, wenn iced das Feld zeichnet.

Bleibt noch die Frage: Welche Nummer hat eigentlich welches Feld? Wir zählen wie beim Lesen: oben links ist Feld 0, dann nach rechts bis Feld 8, und in der nächsten Reihe geht es mit 9 weiter. Die Nummer eines Feldes ist also Reihe mal Breite plus Spalte. Genau das rechnen wir in den Schleifen aus. Dafür brauchen wir jetzt x und y, also fallen die Unterstriche weg:

src/main.rsin der Funktion view, die Schleifen
        for _y in 0..HOEHE {
            let mut reihe = row![].spacing(3);
            for _x in 0..BREITE {
                reihe = reihe.push(sandfeld());
            }        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);
        }
Warum eine lange Liste statt einer Tabelle? Man könnte das Watt auch als Tabelle mit Reihen und Spalten speichern. Eine einzige, durchnummerierte Liste ist aber einfacher zu handhaben, und aus der Nummer lässt sich jederzeit zurückrechnen, wo ein Feld liegt. Feld 40 zum Beispiel liegt in Reihe 40 / 9 = 4 und Spalte 40 % 9 = 4, also genau in der Mitte. Das % ist der Rest beim Teilen. Das werden wir in Etappe 3 brauchen.
Worauf du achten musst Achte auf move vor |_theme: &Theme|. Ohne move meldet Rust einen Fehler, weil die Closure sich grund nur ausleihen würde, grund aber am Ende der Funktion verschwindet. Mit move bekommt die Closure ihre eigene Kopie.
Probelauf Speichern, dann im Terminal:
cargo run
Das Watt sieht noch aus wie vorher, denn alle Felder sind ja verdeckt. Die Warnung zu felder und zustand sind aber verschwunden, denn jetzt lesen wir die Felder. Übrig ist noch die Warnung zu Aufgedeckt.
Schritt 3

Klick! Der Briefkasten bekommt Post

Jetzt der spannende Teil: Ein Klick auf ein Feld soll es aufdecken. Dafür brauchen wir drei Dinge. Erstens eine Nachricht, die sagt, welches Feld angeklickt wurde. Zweitens etwas, das auf Klicks lauscht und dann diese Nachricht verschickt. Und drittens eine Stelle in update, die die Nachricht liest und das Feld aufdeckt.

Fürs Lauschen hat iced ein Widget namens mouse_area, einen „Mausbereich“. Es legt sich unsichtbar über ein anderes Widget und merkt, wenn dort geklickt wird. Das holen wir uns zuerst:

src/main.rsganz oben, erste Zeile
use iced::widget::{column, container, row, text};use iced::widget::{column, container, mouse_area, row, text};

Jetzt bekommt unser Briefkasten seine erste Briefsorte:

src/main.rsdas enum Message
#[derive(Debug, Clone)]enum Message {}enum Message {
    Aufdecken(usize),
}

Aufdecken(usize) ist eine Variante, die einen Wert mitträgt: die Nummer des Feldes. Ein Brief „Aufdecken(12)“ bedeutet also „Bitte Feld 12 aufdecken“. Das #[derive(Debug, Clone)] darüber bleibt stehen, iced braucht es.

Wer Briefe verschickt, muss sie auch lesen. Das macht update:

src/main.rsin impl Watt, Funktion update
    fn update(&mut self, message: Message) {        match message {}        match message {
            Message::Aufdecken(i) => {
                self.felder[i].zustand = Zustand::Aufgedeckt;
            }
        }    }

match schaut nach, welche Art von Brief gekommen ist. Bei Message::Aufdecken(i) holt es gleich die Feldnummer heraus und legt sie in i. Dann setzen wir den Zustand dieses Feldes auf Aufgedeckt. Weil update &mut self bekommt, darf es unser Watt verändern, im Gegensatz zu view, das nur schauen darf.

Fehlt noch der Absender. In feld_ansicht geben wir dem Container einen Namen, kachel, und legen am Ende einen Mausbereich darüber:

src/main.rsin der Funktion feld_ansicht
        let grund = if verdeckt { SAND } else { NASS };        container(text(""))        let kachel = container(text(""))            .width(FELD)    ⋮                ..container::Style::default()            })
            .into()            });

        mouse_area(kachel).on_press(Message::Aufdecken(i)).into()    }
}

Zweimal ändert sich etwas: Oben steht jetzt let kachel = vor dem Container, und unten endet der Container mit einem Semikolon statt mit .into(). Danach kommt die neue letzte Zeile: Leg einen Mausbereich über die Kachel, und wenn jemand darauf klickt (on_press), verschick den Brief Aufdecken(i).

Worauf du achten musst Das Semikolon hinter }) am Ende des Containers ist jetzt wichtig, denn dort endet der let-Befehl. Die letzte Zeile mit mouse_area dagegen hat kein Semikolon, sie ist ja das, was die Funktion zurückgibt.
Probelauf Speichern, dann im Terminal:
cargo run
Klick auf ein Feld: Es wird dunkler und verliert seinen Rand. Du bist aufs Watt getreten! Klick ruhig noch ein paar Felder an. Und im Terminal ist die letzte Warnung verschwunden.
Schritt 4

Die Hand über dem Sand

Eine Kleinigkeit, die viel ausmacht: Wenn die Maus über einem Feld schwebt, soll der Mauszeiger zur Hand mit ausgestrecktem Finger werden, so wie bei Links auf Webseiten. Das zeigt dem Spieler ohne ein einziges Wort: Hier kann man klicken. Dafür brauchen wir mouse aus iced:

src/main.rsganz oben, zweite Zeile
use iced::{Background, Border, Color, Element, Fill, Font, Theme};use iced::{mouse, Background, Border, Color, Element, Fill, Font, Theme};

Und dann eine zusätzliche Zeile beim Mausbereich. Weil die Zeile damit ziemlich lang wird, verteilen wir sie auf mehrere Zeilen, genau wie bei den Containern:

src/main.rsin der Funktion feld_ansicht, letzte Zeile
        mouse_area(kachel).on_press(Message::Aufdecken(i)).into()        mouse_area(kachel)
            .on_press(Message::Aufdecken(i))
            .interaction(mouse::Interaction::Pointer)
            .into()    }

mouse::Interaction::Pointer ist der Name für die Zeigehand. iced kennt noch mehr Mauszeiger, zum Beispiel Grab (die greifende Hand) oder Text (den Strich zum Textmarkieren).

Probelauf Speichern, dann im Terminal:
cargo run
Fahr mit der Maus übers Watt: Über jedem Feld wird der Mauszeiger zur Hand. Klicken deckt die Felder weiter auf wie zuvor.
Das Watt mit einigen aufgedeckten Feldern
Stand nach Schritt 4, nach ein paar Klicks
Zwischenstand

Der komplette Code nach Etappe 2

Hier ist der vollständige Stand nach Etappe 2. 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"

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);

// 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;

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 {
    zustand: Zustand,
}

// Das ganze Watt: eine Liste aller Felder.
struct Watt {
    felder: Vec<Feld>,
}

#[derive(Debug, Clone)]
enum Message {
    Aufdecken(usize),
}

impl Watt {
    // Legt ein neues Watt an, in dem alle Felder verdeckt sind.
    fn neu() -> Self {
        let felder = vec![Feld { zustand: Zustand::Verdeckt }; BREITE * HOEHE];
        Watt { felder }
    }

    fn update(&mut self, message: Message) {
        match message {
            Message::Aufdecken(i) => {
                self.felder[i].zustand = Zustand::Aufgedeckt;
            }
        }
    }

    fn view(&self) -> Element<'_, Message> {
        let titel = text("WATTLÄUFER").size(22).color(SCHAUM);

        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, 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 { NASS };

        let kachel = container(text(""))
            .width(FELD)
            .height(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()
    }
}
ZusammenfassungMit einem enum beschreiben wir Dinge, die genau eine von mehreren festen Möglichkeiten sind. Ein struct bündelt, was zusammengehört, und ein Vec ist eine nummerierte Liste. Ein Klick wird über mouse_area zu einer Nachricht, update liest sie per match und verändert den Zustand, und view zeichnet danach alles neu. Dieser Kreislauf ist das Herz jedes iced-Programms.
Übungen

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.

  1. Zurück in den Sand: Ändere update so, dass ein Klick auf ein schon aufgedecktes Feld es wieder zudeckt. Tipp: Du brauchst ein if, das den aktuellen Zustand prüft.
  2. Wo ist Feld 40? Ändere neu so, dass Feld 40 schon beim Start aufgedeckt ist. Wo erscheint es, und warum genau dort?
  3. Ohne move: Nimm das move vor der Closure in feld_ansicht heraus. Was sagt Rust? Leg es danach wieder zurück.
Lösungen anzeigen

1. Zum Beispiel so:
if self.felder[i].zustand == Zustand::Verdeckt { self.felder[i].zustand = Zustand::Aufgedeckt; } else { self.felder[i].zustand = Zustand::Verdeckt; }
Weil Zustand nur zwei Möglichkeiten hat, wechselt jeder Klick einfach zwischen beiden hin und her.

2. Nach dem Anlegen der Liste in neu: let mut felder = …; (das mut ist jetzt nötig!) und dann felder[40].zustand = Zustand::Aufgedeckt;. Feld 40 erscheint genau in der Mitte: Reihe 40 / 9 = 4 und Spalte 40 % 9 = 4, und weil wir bei 0 anfangen zu zählen, ist 4 die mittlere von neun Reihen und Spalten.

3. Rust meldet einen Fehler, sinngemäß: Die Closure könnte länger leben als die Variablen, die sie sich ausleiht (closure may outlive the current function). Rust schlägt sogar selbst vor, move davorzuschreiben. Das ist typisch für Rust: Die Fehlermeldungen enthalten oft schon die Lösung.

Etappe 2 geschafft 👣

Dein Watt hat ein Gedächtnis, und jeder Klick deckt ein Feld auf. Damit hast du den wichtigsten Kreislauf von iced gebaut. In der nächsten Etappe wird es ernst: Wir verstecken Krebse unter dem Sand, und die Felder fangen an zu zählen.

Hat dir das Mitbauen Spaß gemacht? Ein Kaffee hilft, dass die nächste Werkstatt ein Stück schneller fertig wird.

☕ Kaffee spendieren