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.
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.
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:
#[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 Zustandzählt die Möglichkeiten auf:VerdecktoderAufgedeckt. Eine dritte gibt es nicht, und darauf kann sich Rust verlassen. Mehr zu Enums: Buch, Kapitel 10.struct Feldbeschreibt ein einzelnes Feld. Im Moment steht darin nur sein Zustand, in Etappe 3 kommt noch dazu, ob ein Krebs darunter steckt.struct Watthat jetzt eine Liste aller Felder.Vec<Feld>heißt „eine Liste von Feldern“. EinVecist 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:
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:
iced::application(Watt::default, Watt::update, Watt::view)iced::application(Watt::neu, Watt::update, Watt::view).title("Wattläufer")
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.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:
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:
} }// 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:
..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:
&selfin der ersten Zeile heißt: Die Funktion darf in unserWatthineinschauen (aber nichts verändern). Undiist die Nummer des Feldes, das gezeichnet werden soll.self.felder[i]holt das Feld mit der Nummeriaus der Liste, also den Inhalt von Schublade Nummeri.feld.zustand == Zustand::Verdecktvergleicht, ob das Feld verdeckt ist. Das Ergebnis isttrueoderfalseund landet inverdeckt.if verdeckt { SAND } else { NASS }wählt die Farbe. In Rust liefert einifdirekt einen Wert, das ist sehr praktisch.- Das
movevor der Closure bei.style(…)ist wichtig: Es packtgrundundverdecktin 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:
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); }
% ist der Rest beim Teilen. Das werden wir in Etappe 3 brauchen.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.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.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:
use iced::widget::{column, container, row, text};use iced::widget::{column, container, mouse_area, row, text};
Jetzt bekommt unser Briefkasten seine erste Briefsorte:
#[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:
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:
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).
}) 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.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.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:
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:
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).
cargo run
Fahr mit der Maus übers Watt: Über jedem Feld wird der Mauszeiger zur Hand.
Klicken deckt die Felder weiter auf wie zuvor.
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()
}
}
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.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.
- Zurück in den Sand: Ändere
updateso, dass ein Klick auf ein schon aufgedecktes Feld es wieder zudeckt. Tipp: Du brauchst einif, das den aktuellen Zustand prüft. - Wo ist Feld 40? Ändere
neuso, dass Feld 40 schon beim Start aufgedeckt ist. Wo erscheint es, und warum genau dort? - Ohne move: Nimm das
movevor der Closure infeld_ansichtheraus. 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