Wir bauen ein Spiel – das Watt
Schön, dass du da bist! In dieser Werkstatt bauen wir zusammen ein kleines Spiel: den Wattläufer. Es ist Ebbe, unter dem Sand haben sich Krebse versteckt, und du musst übers Watt laufen, ohne einem auf die Scheren zu treten. Wer Minesweeper kennt, kennt die Regeln schon. Heute legen wir das Spielfeld an: ein Fenster mit Meer drumherum und 81 Sandfeldern.
Bevor wir loslegen
Worum geht es?
Das Spielfeld besteht aus 9 mal 9 Feldern Sand. Unter zehn davon steckt ein Krebs. Klickst du ein Feld an, deckst du es auf. Ist dort kein Krebs, erscheint eine Zahl: Sie verrät dir, wie viele Krebse in den acht Feldern rundherum stecken. Mit etwas Nachdenken kannst du so herausfinden, wo die Krebse sitzen, und dort mit einem Rechtsklick ein Warnfähnchen aufstellen. Hast du alle sicheren Felder aufgedeckt, hast du gewonnen. Trittst du auf einen Krebs, zwickt er zu, und das Spiel ist vorbei.
Bis das alles funktioniert, gehen wir sechs Etappen. Heute geht es nur um das Aussehen, die Spielregeln kommen danach Stück für Stück dazu.
Was du brauchst
- Rust, installiert über
rustup. Falls du das noch nicht hast: Buch, Kapitel 1 zeigt dir Schritt für Schritt, wie es geht. - Einen Editor, am besten VS Code mit der Erweiterung
rust-analyzer. - Ein Terminal, in dem du Befehle eintippst.
Mehr nicht. Du musst weder iced kennen noch schon viel Rust können. Alles, was wir brauchen, erkläre ich genau in dem Moment, in dem wir es brauchen.
So liest du die Code-Kästen
Wir schreiben nicht bei jedem Schritt das ganze Programm neu. Stattdessen zeige ich dir genau die Stelle, an der sich etwas ändert. Oben im Kasten stehen der Dateiname und ein kurzer Hinweis, wo die Stelle liegt. Die Zeilen im Kasten sind farbig markiert:
Nach fast jedem Schritt machen wir einen Probelauf. Kleine Schritte, oft prüfen: So findest du Tippfehler sofort und nicht erst zwanzig Minuten später.
Das Projekt anlegen
Jedes Rust-Programm beginnt mit einem Projektordner, und den legt cargo für
uns an. Öffne ein Terminal, geh in den Ordner, in dem du deine Projekte sammelst, und
tippe:
cargo new wattlaeufer
cd wattlaeufer
cargo new erzeugt einen Ordner wattlaeufer mit allem, was ein
Rust-Projekt braucht, und mit cd gehen wir hinein. Jetzt holen wir uns
iced dazu, die Bibliothek, mit der wir Fenster, Texte und Spielfelder
bauen:
cargo add iced@0.14
In der Datei Cargo.toml, sozusagen dem Personalausweis deines Projekts, steht
jetzt ganz unten iced als Abhängigkeit:
[package]
name = "wattlaeufer"
version = "0.1.0"
edition = "2024"
[dependencies]
iced = "0.14"
ae statt ä, weil Rust in
Projektnamen nur einfache Buchstaben, Ziffern, - und _ erlaubt. Im
Fenster darf später natürlich „Wattläufer“ stehen. Mit @0.14 legen wir die
Version von iced fest, damit diese Anleitung auch dann noch funktioniert, wenn eine neuere
Version erscheint, die manches anders macht.Das erste Fenster
Jetzt bringen wir ein Fenster auf den Bildschirm. Öffne src/main.rs. Dort
steht schon ein kleines „Hello, world!“-Programm, das wir nicht brauchen. Lösch den
ganzen Inhalt und tippe stattdessen Folgendes ein. Das ist das einzige Mal, dass wir
eine Datei komplett schreiben. Danach kommen nur noch einzelne Stellen dazu.
use iced::widget::text;
use iced::Element;
fn main() -> iced::Result {
iced::application(Watt::default, Watt::update, Watt::view)
.title("Wattläufer")
.run()
}
#[derive(Default)]
struct Watt {}
#[derive(Debug, Clone)]
enum Message {}
impl Watt {
fn update(&mut self, message: Message) {
match message {}
}
fn view(&self) -> Element<'_, Message> {
text("Ebbe! Das Watt liegt vor dir.").into()
}
}
Das sind gut 20 Zeilen, aber es steckt schon das ganze Prinzip von iced darin. Gehen wir sie gemeinsam durch:
use …holt Dinge aus iced in unsere Datei, damit wir sie mit ihrem kurzen Namen benutzen können.textzeigt einen Text an,Elementist der Oberbegriff für „irgendetwas, das man anzeigen kann“.fn main()ist der Startpunkt jedes Rust-Programms. Hier sagen wir iced: „Starte eine Anwendung.“ Dafür bekommt iced drei Dinge: wie man den Anfangszustand erzeugt (Watt::default), was bei einem Ereignis passieren soll (Watt::update) und wie der Bildschirm aussieht (Watt::view)..title(…)setzt den Fenstertitel,.run()startet das Ganze.struct Wattist das Gedächtnis unseres Spiels. Später steht hier, wo die Krebse stecken und welche Felder schon aufgedeckt sind. Im Moment ist es noch leer.enum Messageist der Briefkasten. Jedes Ereignis, auf das das Spiel reagieren soll, zum Beispiel „Feld Nummer 12 wurde angeklickt“, wird ein Brief in diesem Kasten. Noch gibt es keine Ereignisse, also ist er leer.updateöffnet die Briefe und entscheidet, was passiert. Ohne Briefe steht inmatch message {}nichts drin.viewbeschreibt, was auf dem Bildschirm zu sehen ist. Das.into()verpackt den Text in einElement, denn genau das will iced zurückbekommen.
Dieses Dreieck aus Zustand, update und view begleitet uns durch das ganze Spiel. Im Buch gibt es dazu eine ausführliche Erklärung: Buch, Kapitel 17.
Watt::default, Watt::update und
Watt::view stehen keine runden Klammern dahinter. Wir rufen
die Funktionen hier nicht selbst auf, sondern geben sie iced in die Hand, und iced ruft sie
auf, wann immer es sie braucht. Das #[derive(Default)] über dem
struct sorgt dafür, dass es Watt::default überhaupt gibt.cargo run
Der erste Start dauert ein paar Minuten, weil cargo iced und alles, was
dazugehört, einmal übersetzt. Das passiert nur beim ersten Mal. Wenn alles klappt, öffnet
sich ein Fenster mit dem Titel „Wattläufer“ und dem Text Ebbe! Das Watt liegt vor
dir. Schließ es wieder, bevor wir weitermachen.Schrift und Fenstergröße
Unser Spiel soll ein bisschen nach alter Spielkonsole aussehen. Dazu passt eine
Monospace-Schrift, bei der jeder Buchstabe gleich breit ist, wie auf einer
Schreibmaschine. Außerdem geben wir dem Fenster eine feste Größe, in die unser Spielfeld
später genau hineinpasst. Zuerst brauchen wir Font aus iced:
use iced::widget::text;use iced::Element;use iced::{Element, Font};
Die geschweiften Klammern bedeuten: „Hol mir aus iced gleich mehrere Dinge
auf einmal.“ Dann ergänzen wir in main zwei Zeilen direkt unter dem Titel:
fn main() -> iced::Result { iced::application(Watt::default, Watt::update, Watt::view) .title("Wattläufer").default_font(Font::MONOSPACE) .window_size((460.0, 560.0)).run() }
default_font legt die Schrift für das ganze Programm fest.
window_size bekommt Breite und Höhe in Pixeln. Die doppelten Klammern sind
kein Tippfehler: Die inneren packen die zwei Zahlen zu einem Paar zusammen, die äußeren
gehören zum Funktionsaufruf.
application(…) ist die Lok, .run() der letzte Wagen. Ein Semikolon
mittendrin würde den Zug auseinanderreißen.cargo run
Das Fenster ist jetzt schmaler und höher, und der Text sieht aus wie getippt.Das Meer
Rund um unser Spielfeld soll das Meer sein, ein tiefes Nordsee-Blau. Dafür brauchen wir
zwei Dinge: eine Farbe und etwas, das diese Farbe als Hintergrund tragen kann. Dieses Etwas
heißt in iced Container. Stell ihn dir als Kiste vor: Die Kiste kann eine
Farbe haben, eine Größe und einen Innenabstand zum Inhalt. Für all das brauchen wir wieder
ein paar Dinge aus iced. Diesmal ändern sich beide use-Zeilen:
use iced::widget::text; use iced::{Element, Font};use iced::widget::{container, text}; use iced::{Background, Color, Element, Fill, Font, Theme};
Jetzt legen wir unsere Farben fest, und zwar zwischen den use-Zeilen und
fn main():
use iced::{Background, 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);fn main() -> iced::Result {
Mit const geben wir einem festen Wert einen Namen, das nennt man eine
Konstante. Jede Farbe mischt sich aus Rot, Grün und Blau, jeweils von 0 bis
255. Hier schreiben wir die Werte als Hexadezimalzahlen, also mit
0x davor: 0x1f ist 31, 0xf4 ist 244. So stehen
Farben auch in Grafikprogrammen und auf Webseiten, deshalb ist diese Schreibweise praktisch
zum Abgucken. MEER ist das Blau, SCHAUM ein warmes Weiß für
Schrift.
Und nun zur Funktion view. Die alte Textzeile ersetzen wir durch eine
Überschrift in einer blauen Kiste:
fn view(&self) -> Element<'_, Message> {text("Ebbe! Das Watt liegt vor dir.").into()let titel = text("WATTLÄUFER").size(22).color(SCHAUM); container(titel) .padding(20) .width(Fill) .height(Fill) .style(|_theme: &Theme| container::Style { background: Some(Background::Color(MEER)), ..container::Style::default() }) .into()} }
Schauen wir es uns Stück für Stück an:
text("WATTLÄUFER").size(22).color(SCHAUM)baut die Überschrift, 22 Pixel groß, in Schaumweiß. Mitlet titel = …geben wir ihr einen Namen.container(titel)packt die Überschrift in eine Kiste..padding(20)lässt 20 Pixel Abstand zum Rand,.width(Fill)und.height(Fill)machen die Kiste so groß wie das ganze Fenster..style(…)legt das Aussehen fest. In den Klammern steht eine kleine Funktion ohne Namen, eine Closure. Zwischen den Strichen|…|steht, was sie bekommt: das Farbschema (Theme). Das brauchen wir nicht, deshalb der Unterstrich vor dem Namen. Mehr über Closures: Buch, Kapitel 13.- Die Closure liefert einen
container::Style: Hintergrund ist das Meer.Some(…)heißt „ja, hier gibt es einen Wert“, und..container::Style::default()heißt „alles andere bitte wie üblich“.
.into() hat kein Semikolon. In
Rust ist die letzte Zeile ohne Semikolon das, was eine Funktion zurückgibt. Mit Semikolon
würde view nichts zurückgeben, und der Compiler würde sich beschweren.cargo run
Das ganze Fenster ist jetzt tiefblau, und oben links steht in hellen Buchstaben
WATTLÄUFER. Das Meer ist da, fehlt nur noch der Sand.Ein einzelnes Sandfeld
Jetzt kommt der Sand. Das Spielfeld besteht aus 81 gleichen Feldern. Bevor wir uns an alle 81 wagen, bauen wir erst mal eines. Und weil wir es danach 81-mal brauchen, schreiben wir dafür eine Funktion: eine Ausstechform, die wir einmal bauen und dann beliebig oft benutzen.
Ein Sandfeld ist wieder ein Container, diesmal mit fester Größe, sandfarbenem Hintergrund
und einem etwas dunkleren, abgerundeten Rand. Den Rand beschreibt iced mit
Border. Um das Feld unter die Überschrift zu stellen, brauchen wir außerdem
column. Also zuerst wieder die use-Zeilen:
use iced::widget::{container, text}; use iced::{Background, Color, Element, Fill, Font, Theme};use iced::widget::{column, container, text}; use iced::{Background, Border, Color, Element, Fill, Font, Theme};
Dann zwei neue Farben und die Feldgröße, direkt unter SCHAUM:
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); // So groß ist ein Feld, in Pixeln const FELD: f32 = 44.0;
Bei FELD steht f32 statt Color: Das ist eine
Kommazahl, denn Größen misst iced in Kommazahlen. Deshalb auch 44.0 statt
44. Jetzt die Ausstechform selbst. Sie kommt ganz ans Ende der
Datei, hinter die letzte geschweifte Klammer von impl Watt:
.into() } }// 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() }
text("") ist ein leerer Text, denn das Feld soll ja erst mal nichts
anzeigen. Border beschreibt den Rand: dunklerer Sand, 2 Pixel breit, Ecken mit
6 Pixel Rundung. Das 'static in der ersten Zeile sagt Rust, dass das Feld
nichts aus unserem Spielzustand ausleiht. Es lebt also völlig unabhängig, so lange es
gebraucht wird. Mehr musst du dir dazu im Moment nicht merken.
Zum Schluss stellen wir in view die Überschrift und ein Sandfeld
untereinander. Dafür ist column! da, eine Spalte, die ihren Inhalt von oben
nach unten stapelt:
let titel = text("WATTLÄUFER").size(22).color(SCHAUM);container(titel)let inhalt = column![titel, sandfeld()].spacing(14); container(inhalt).padding(20)
.spacing(14) lässt 14 Pixel Luft zwischen den Teilen der Spalte. Und in den
Container kommt jetzt nicht mehr nur der Titel, sondern die ganze Spalte.
sandfeld muss außerhalb von
impl Watt { … } stehen. Landet sie aus Versehen vor der letzten schließenden
Klammer, meldet Rust cannot find function `sandfeld`. Zähl im Zweifel die
Klammern nach, oder lass cargo fmt den Code einrücken, dann sieht man es
sofort. Und bei column! bitte das Ausrufezeichen und die eckigen Klammern
nicht vergessen, denn column! ist ein Makro.cargo run
Unter der Überschrift liegt jetzt ein einzelnes, sandfarbenes Feld mit runden
Ecken. Ein bisschen einsam, aber das ändern wir gleich.Eine Reihe Sand
Jetzt benutzen wir unsere Ausstechform mehrmals. Neun Felder sollen nebeneinander
liegen. Wir könnten sandfeld() einfach neunmal hinschreiben, aber es geht
eleganter: mit einer Schleife, die dieselbe Arbeit so oft wiederholt, wie
wir wollen. Wie viele Felder breit das Watt ist, halten wir in einer Konstanten fest:
const FELD: f32 = 44.0;// So groß ist das Watt const BREITE: usize = 9;fn main() -> iced::Result {
usize ist der Zahlentyp, den Rust für Anzahlen und Positionen benutzt. Er
kann nicht negativ werden, denn eine Reihe mit minus drei Feldern gibt es nicht. Für
„nebeneinander“ brauchen wir das Gegenstück zu column!:
row!, eine Reihe von links nach rechts.
use iced::widget::{column, container, text};use iced::widget::{column, container, row, text};
Und nun die Schleife in view:
let titel = text("WATTLÄUFER").size(22).color(SCHAUM);let inhalt = column![titel, sandfeld()].spacing(14);let mut reihe = row![].spacing(3); for _x in 0..BREITE { reihe = reihe.push(sandfeld()); } let inhalt = column![titel, reihe].spacing(14);
Das liest sich fast wie ein Satz:
let mut reihe = row![].spacing(3);legt eine leere Reihe an, mit 3 Pixeln Abstand zwischen den Feldern. Dasmutbedeutet „veränderbar“: Wir wollen gleich Felder hinzufügen, und Rust erlaubt das nur, wenn wir es vorher ankündigen.for _x in 0..BREITEheißt: „Mach das Folgende für jede Zahl von 0 bis 8.“0..BREITEzählt nämlich bis vor 9. Der Unterstrich vor_xsagt Rust wieder: „Den Wert brauche ich gerade nicht, alles gut.“reihe = reihe.push(sandfeld());hängt ein neues Feld an und merkt sich die verlängerte Reihe. Daspushgibt nämlich eine neue, längere Reihe zurück, und die speichern wir wieder unter demselben Namen.
cargo run
Unter der Überschrift liegen jetzt neun Sandfelder in einer Reihe, fast genau
so breit wie das Fenster.Das ganze Watt
Eine Reihe haben wir. Jetzt brauchen wir neun davon, übereinander. Und das Schöne ist: Wir können einfach eine Schleife um unsere Schleife legen. Die äußere Schleife sorgt für die Reihen, die innere für die Felder in jeder Reihe. Erst die Höhe des Watts als Konstante:
const BREITE: usize = 9;const HOEHE: usize = 9;
Rust schreibt man HOEHE mit OE, denn in Namen im Code verzichten
wir auf Umlaute. Rust würde sie sogar erlauben, aber es ist unter Rust-Programmierern
weltweit üblich, Namen nur aus einfachen Buchstaben zu bilden. Und nun die Schleifen:
let titel = text("WATTLÄUFER").size(22).color(SCHAUM);let mut reihe = row![].spacing(3); for _x in 0..BREITE { reihe = reihe.push(sandfeld()); } let inhalt = column![titel, reihe].spacing(14);let mut watt = column![].spacing(3); for _y in 0..HOEHE { let mut reihe = row![].spacing(3); for _x in 0..BREITE { reihe = reihe.push(sandfeld()); } watt = watt.push(reihe); } let inhalt = column![titel, watt].spacing(14);
Stell dir vor, du legst Fliesen: Für jede Reihe (_y) legst du neun Fliesen
nebeneinander (_x), und wenn die Reihe fertig ist, legst du sie auf den Stapel
(watt.push(reihe)). Neun Reihen mal neun Fliesen, macht 81 Sandfelder. Das
Watt ist eine Spalte aus Reihen, darum ist watt ein column!.
let mut reihe = … jetzt innerhalb
der äußeren Schleife steht. Für jede Reihe fangen wir mit einer neuen, leeren Reihe an.
Stünde sie außerhalb, würde Rust sich beschweren, dass wir eine Reihe benutzen, die wir
schon auf den Stapel gelegt haben.cargo run
Da ist es: das ganze Watt, 9 mal 9 Sandfelder, umgeben von tiefblauem Meer.
Klicken kannst du noch nicht, aber das Spielfeld steht.
Der komplette Code nach Etappe 1
Hier ist der vollständige Stand nach Etappe 1. 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, row, text};
use iced::{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);
// 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::default, Watt::update, Watt::view)
.title("Wattläufer")
.default_font(Font::MONOSPACE)
.window_size((460.0, 560.0))
.run()
}
#[derive(Default)]
struct Watt {}
#[derive(Debug, Clone)]
enum Message {}
impl Watt {
fn update(&mut self, message: Message) {
match message {}
}
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 {
reihe = reihe.push(sandfeld());
}
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()
}
}
// 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()
}
struct), einer update-Funktion für
Ereignisse und einer view-Funktion für den Bildschirm. Mit
container geben wir Dingen Farbe, Größe und Rand, mit column!
stapeln wir sie, mit row! legen wir sie nebeneinander. Wiederkehrende Bausteine
schreiben wir einmal als Funktion, und mit for-Schleifen bauen wir daraus
beliebig viele. Werte, die sich nie ändern, stehen als const an einer
einzigen Stelle.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.
- Riesenwatt: Ändere
BREITEundHOEHEauf 12. Was passiert mit dem Fenster? Was müsstest du zusätzlich ändern, damit wieder alles hineinpasst? - Ohne Unterstrich: Schreib in einer der Schleifen
xstatt_xund starte das Programm. Was sagt dir Rust? Ist das ein Fehler oder eine Warnung? - Farbenlehre: Mach das Meer mit
Color::from_rgb8(0x2e, 0x7d, 0x4f)grün. Wie viele Zeilen musstest du ändern, und warum nicht mehr?
Lösungen anzeigen
1. Das Watt wird größer als das Fenster und rechts und unten abgeschnitten, denn das
Fenster ist fest auf 460 mal 560 Pixel eingestellt. Du müsstest in main die
window_size vergrößern (etwa auf (600.0, 700.0)) oder
FELD kleiner machen.
2. Rust gibt eine gelbe Warnung aus: unused variable: `x`, mit
dem Hinweis, man solle _x schreiben, falls das Absicht ist. Das Programm läuft
trotzdem. Rust weist dich nur darauf hin, dass du einer Sache einen Namen gegeben hast,
die du nie benutzt, denn oft ist so etwas ein Versehen.
3. Genau eine Zeile: die Konstante MEER. Weil die Farbe nur an dieser einen
Stelle festgelegt ist, ändert sie sich überall, wo MEER verwendet wird.
Genau dafür sind Konstanten da.
Etappe 1 geschafft 🏖️
Du hast ein Rust-Projekt angelegt, ein Fenster gebaut und mit zwei Schleifen ein ganzes Watt aus 81 Sandfeldern gelegt. In der nächsten Etappe bekommt das Spiel ein Gedächtnis, und die Felder lassen sich anklicken.
Hat dir das Mitbauen Spaß gemacht? Ein Kaffee hilft, dass die nächste Werkstatt ein Stück schneller fertig wird.
☕ Kaffee spendieren