Rust-Werkstatt
Werkstatt · Baulöwe · Etappe 1 von 10

Das Baugrundstück

Willkommen auf der Baustelle! In dieser Werkstatt bauen wir ein Wirtschaftsspiel: Du gründest Hotelketten, kaufst Aktien, schluckst die Konkurrenz und trittst dabei gegen Computergegner an. Heute legen wir das Fundament, und zwar wörtlich: ein Spielbrett schräg von oben gesehen, mit Straßen, Rasen, Feldnamen und den ersten Häusern. Alles, was du siehst, zeichnet unser eigener Code. Es gibt keine einzige Bilddatei.

9 Schritte · ca. 75 Minuten · iced 0.14 · Rust 1.88 oder neuer · Linux, macOS, Windows

Ein Fenster mit hellblauem Himmel und einer Sonne. Darauf ein schräg von oben gesehenes Spielbrett aus 12 mal 9 grünen Parzellen mit Namen wie 3C und 7F, dazwischen graue Straßen. Auf einigen Parzellen stehen dreistöckige Häuser mit blauen Fenstern.
So sieht der Baulöwe am Ende dieser Etappe aus.
Vorab

Bevor wir loslegen

Worum geht es?

Der Baulöwe ist unsere Version eines Klassikers unter den Wirtschaftsspielen. Auf einem Brett mit 108 Feldern (Spalten 1 bis 12, Reihen A bis I) legen die Spieler reihum Bauplättchen. Liegen Plättchen nebeneinander, entsteht eine Hotelkette. Wer eine Kette gründet, bekommt eine Gratis-Aktie, und jeder darf Aktien kaufen. Je größer eine Kette wird, desto mehr ist sie wert. Stoßen zwei Ketten zusammen, schluckt die große die kleine, und die größten Aktionäre der geschluckten Kette kassieren eine Prämie. Am Ende gewinnt, wer das meiste Geld hat.

Mitspielen wirst du gegen Computergegner in drei Stärken, vom planlosen Touristen bis zum Hai, der tausende Partien im Kopf durchrechnet, bevor er zieht. Bis dahin sind es zehn Etappen. Heute geht es nur um das, was man sieht: das Brett und die ersten Häuser.

Eine echte Desktop-App

Der Baulöwe läuft nicht im Browser, sondern als eigenes Programm mit eigenem Fenster, auf Linux, macOS und Windows aus genau demselben Code. Dafür sorgt iced, die Rust-Bibliothek für grafische Oberflächen, die du vielleicht schon aus dem Buch oder dem Wattläufer kennst. Diesmal nutzen wir vor allem ihre Leinwand (englisch canvas): eine Fläche, auf die wir frei zeichnen dürfen, Punkt für Punkt, Fläche für Fläche.

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.

Der Baulöwe ist etwas größer als der Wattläufer, und es kommt ein bisschen Mathematik vor. Keine Sorge: Mehr als Plus, Minus, Mal und Geteilt brauchst du nicht, und jede Formel erkläre ich an einem Beispiel. Wenn du iced noch nie benutzt hast, ist der Wattläufer ein sanfterer Einstieg. Nötig ist er aber nicht.

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:

fn main() {Grau: steht schon in deiner Datei. Nur zur Orientierung, nicht noch einmal eintippen. let x = 1;Türkiser Balken: das ist neu, das tippst du ein. let y = 2;Roter Balken, durchgestrichen: diese Zeile löschst du (meistens, weil direkt darunter der Ersatz steht). ⋮Drei Punkte: hier steht Code, den wir gerade nicht anfassen.

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.

Abgehängt? Kein Problem. Am Ende der Etappe findest du unter Zwischenstand den kompletten Code. Damit kannst du jederzeit vergleichen oder neu einsteigen.
Schritt 1

Das Projekt anlegen

Öffne ein Terminal, geh in den Ordner, in dem du deine Projekte sammelst, und lass cargo ein neues Projekt anlegen:

cargo new bauloewe
cd bauloewe

Jetzt holen wir iced dazu. Diesmal mit einem Zusatz:

cargo add iced@0.14 --features canvas

Viele Bibliotheken bestehen aus Bausteinen, die man einzeln einschalten kann. Solche Bausteine heißen Features. Die Leinwand von iced ist so ein Baustein, weil längst nicht jedes Programm frei zeichnen muss. Mit --features canvas schalten wir ihn ein. In der Cargo.toml steht danach:

[package]
name = "bauloewe"
version = "0.1.0"
edition = "2024"

[dependencies]
iced = { version = "0.14", features = ["canvas"] }
Worauf du achten musst Im Projektnamen steht oe statt ö, weil Rust in Projektnamen nur einfache Buchstaben erlaubt. Im Fenstertitel darf später natürlich „Baulöwe“ stehen. Und falls du die Zeile in der Cargo.toml lieber von Hand schreibst: Die eckigen Klammern um "canvas" gehören dazu, denn dort könnte eine ganze Liste von Features stehen.
Schritt 2

Das erste Fenster

Öffne src/main.rs, lösch den ganzen Inhalt und tippe stattdessen Folgendes ein. Das ist das einzige Mal, dass wir eine Datei komplett schreiben.

src/main.rsdie komplette Datei (alles Alte vorher löschen)
use iced::Element;
use iced::widget::text;

fn main() -> iced::Result {
    iced::application(Bauloewe::default, Bauloewe::update, Bauloewe::view)
        .title("Baulöwe")
        .window_size((980.0, 680.0))
        .antialiasing(true)
        .run()
}

#[derive(Default)]
struct Bauloewe {}

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

impl Bauloewe {
    fn update(&mut self, message: Message) {
        match message {}
    }

    fn view(&self) -> Element<'_, Message> {
        text("Hier entsteht bald eine ganze Stadt.").into()
    }
}

Das ist das Grundgerüst jeder iced-Anwendung:

  • struct Bauloewe ist das Gedächtnis unseres Spiels. Später steht hier alles: wem welche Aktien gehören, welche Ketten es gibt, wer am Zug ist. Im Moment ist es noch leer.
  • enum Message ist der Briefkasten für Ereignisse, etwa „Feld 7C wurde angeklickt“. Noch gibt es keine.
  • update liest die Briefe und ändert das Gedächtnis, view beschreibt, was auf dem Bildschirm zu sehen ist.
  • In main reichen wir iced diese drei Dinge und stellen das Fenster ein: Titel, Größe in Pixeln und antialiasing. Das ist die Kantenglättung. Ohne sie hätten unsere schrägen Linien später Treppenstufen, mit ihr werden sie schön glatt.

Falls dir das Dreieck aus Zustand, update und view noch neu ist: Buch, Kapitel 17 („So denkt iced“) erklärt es ausführlich.

Probelauf Speichern, dann im Terminal:
cargo run
Der erste Start dauert ein paar Minuten, weil cargo iced einmal komplett übersetzt. Dann öffnet sich ein Fenster mit dem Titel „Baulöwe“ und einer Zeile Text.
Schritt 3

Eine Leinwand mit Himmel

Jetzt tauschen wir den Text gegen eine Leinwand. Auf eine Leinwand kann man keine fertigen Knöpfe oder Texte legen, dafür aber alles selbst zeichnen: Flächen, Linien, Kreise, Farbverläufe. Genau das brauchen wir für Häuser und Straßen. Zuerst die use-Zeilen:

src/main.rsganz oben, die beiden use-Zeilen
use iced::Element;
use iced::widget::text;use iced::mouse;
use iced::widget::canvas;
use iced::widget::canvas::{Frame, Path, gradient};
use iced::{Color, Element, Fill, Point, Rectangle, Renderer, Size, Theme};

In view geben wir statt des Textes die Leinwand zurück:

src/main.rsin impl Bauloewe, Funktion view
    fn view(&self) -> Element<'_, Message> {        text("Hier entsteht bald eine ganze Stadt.").into()        canvas(self).width(Fill).height(Fill).into()    }
}

canvas(self) bedeutet: „Die Leinwand soll unser Spiel zeichnen.“ Damit iced weiß, wie man einen Baulöwen zeichnet, müssen wir es ihm beibringen. Dafür gibt es in iced eine Vorlage namens canvas::Program. So eine Vorlage heißt in Rust Trait: eine Liste von Fähigkeiten, die ein Typ haben muss. Wir erfüllen sie mit einem eigenen impl-Block direkt unter dem ersten:

src/main.rsunter dem Block impl Bauloewe { … }
    }
}impl canvas::Program<Message> for Bauloewe {
    type State = ();

    fn draw(
        &self,
        _state: &Self::State,
        renderer: &Renderer,
        _theme: &Theme,
        bounds: Rectangle,
        _cursor: mouse::Cursor,
    ) -> Vec<canvas::Geometry> {
        let mut frame = Frame::new(renderer, bounds.size());
        let farben = Farben::tag();

        self.zeichne_himmel(&mut frame, &farben, bounds.size());

        vec![frame.into_geometry()]
    }
}
  • type State = (); heißt: Die Leinwand braucht kein eigenes Gedächtnis, alles Wichtige steht in Bauloewe. Die leeren Klammern () sind Rusts Art, „nichts“ zu sagen.
  • draw ruft iced jedes Mal auf, wenn neu gezeichnet werden muss. Wir bekommen unter anderem bounds, die aktuelle Größe der Leinwand. Die Parameter mit Unterstrich vorn brauchen wir nicht.
  • Ein Frame ist unser Zeichenblock. Wir zeichnen darauf und geben ihn am Ende als fertiges Bild (Geometry) zurück.

Jetzt fehlen noch zeichne_himmel und die Farben. Beides kommt ganz ans Ende der Datei. Die Zeichenfunktionen sammeln wir in einem zweiten impl Bauloewe-Block, damit das Spiel und das Malen sauber getrennt bleiben:

src/main.rsganz am Ende der Datei
    }
}// Alles, was auf die Leinwand gezeichnet wird
impl Bauloewe {
    fn zeichne_himmel(&self, frame: &mut Frame, farben: &Farben, groesse: Size) {
        let verlauf = gradient::Linear::new(Point::ORIGIN, Point::new(0.0, groesse.height))
            .add_stop(0.0, farben.himmel_oben)
            .add_stop(1.0, farben.himmel_unten);
        frame.fill_rectangle(Point::ORIGIN, groesse, verlauf);

        // Die Sonne: ein weicher Schein aus vielen blassen Kreisen
        let sonne = Point::new(groesse.width * 0.84, groesse.height * 0.2);
        for ring in 0..12 {
            let radius = 30.0 + ring as f32 * 14.0;
            frame.fill(
                &Path::circle(sonne, radius),
                Color::from_rgba(1.0, 0.97, 0.85, 0.05),
            );
        }
        frame.fill(
            &Path::circle(sonne, 26.0),
            Color::from_rgb8(0xff, 0xf6, 0xd8),
        );
    }
}

// Alle Farben an einem Ort
#[derive(Clone, Copy)]
struct Farben {
    himmel_oben: Color,
    himmel_unten: Color,
}

impl Farben {
    fn tag() -> Farben {
        Farben {
            himmel_oben: Color::from_rgb8(0x8e, 0xc5, 0xe8),
            himmel_unten: Color::from_rgb8(0xe9, 0xf2, 0xf7),
        }
    }
}

Hier passiert eine ganze Menge:

  • Der Farbverlauf. gradient::Linear läuft von einem Startpunkt zu einem Endpunkt, hier von ganz oben nach ganz unten. Mit add_stop legen wir fest, welche Farbe an welcher Stelle liegt: bei 0.0 (oben) das kräftige Himmelblau, bei 1.0 (unten) ein fast weißes Blau. Dazwischen mischt iced von selbst.
  • Die Sonne. Einen weichen Lichtschein gibt es in iced nicht fertig, also tricksen wir: Wir malen zwölf Kreise übereinander, jeden etwas größer, jeden fast durchsichtig. Die vierte Zahl in from_rgba ist die Deckkraft (0.0 unsichtbar, 1.0 undurchsichtig). Innen liegen viele Kreise übereinander und es wird hell, außen nur einer und es wird blass. Zum Schluss kommt ein kleiner, voller Kreis als Sonnenscheibe obendrauf.
  • Die Farben sammeln wir in einer eigenen Struktur Farben. Das hat einen Grund, der sich in Schritt 9 auszahlt: Dann bekommt das Spiel einen Nachtmodus, und wir müssen nur eine zweite Farbpalette danebenlegen.
Worauf du achten musst Positionen auf der Leinwand werden in Pixeln von der oberen linken Ecke aus gemessen. x wächst nach rechts, y wächst nach unten, nicht nach oben wie im Matheunterricht. Die Sonne bei groesse.height * 0.2 sitzt also im oberen Fünftel.
Probelauf Speichern, dann im Terminal:
cargo run
Das Fenster zeigt einen Himmel, der nach unten heller wird, und rechts oben eine Sonne mit weichem Schein. Zieh das Fenster größer: Himmel und Sonne passen sich an, weil wir alles aus bounds berechnen.
Schritt 4

Schräg von oben: Isometrie

Jetzt wird es spannend. Wir wollen das Brett nicht platt von oben sehen wie ein Schachbrett, sondern schräg von oben, wie aus einem Hubschrauber. Diese Ansicht heißt Isometrie, und viele Aufbauspiele nutzen sie. Aus einem Quadrat wird dabei eine Raute, die doppelt so breit wie hoch ist.

Die Idee ist einfach. Auf dem Brett gibt es zwei Richtungen: die Spalte s und die Reihe r. Auf dem Bildschirm drehen wir beide schräg:

ursprung s (Spalte) r (Reihe) 0,0 2,2
Ein Schritt in Richtung s geht nach rechts unten, ein Schritt in Richtung r nach links unten.
  • Ein Schritt in Richtung s: 40 Pixel nach rechts und 20 Pixel nach unten.
  • Ein Schritt in Richtung r: 40 Pixel nach links und 20 Pixel nach unten.

Daraus ergeben sich zwei Formeln. Für die waagerechte Position zählt, wie weit s und r auseinanderliegen, für die senkrechte, wie viele Schritte es insgesamt sind:

x = ursprung.x + (s − r) · 40
y = ursprung.y + (s + r) · 20 − z

Ein Beispiel: Das Feld bei s = 2, r = 0 liegt (2 − 0) · 40 = 80 Pixel rechts und (2 + 0) · 20 = 40 Pixel unter dem Ursprung. Das Feld s = 2, r = 2 liegt (2 − 2) · 40 = 0 Pixel daneben, also genau darunter. Das dritte Maß z ist die Höhe: Wer höher steht, erscheint auf dem Bildschirm weiter oben, deshalb ziehen wir z ab. Das brauchen wir ab dem nächsten Schritt für Häuser.

Zuerst die festen Maße. Sie kommen direkt unter die use-Zeilen:

src/main.rszwischen den use-Zeilen und fn main()
use iced::{Color, Element, Fill, Point, Rectangle, Renderer, Size, Theme};
// Das Brett: 12 Spalten (1 bis 12) und 9 Reihen (A bis I)
const SPALTEN: usize = 12;
const REIHEN: usize = 9;

// So groß erscheint ein Feld auf dem Bildschirm
const HALBE_BREITE: f32 = 40.0;
const HALBE_HOEHE: f32 = 20.0;
fn main() -> iced::Result {

Die Umrechnung packen wir in eine kleine Struktur Iso. Sie merkt sich, wo der Ursprung liegt, und kann dann jeden Punkt des Bretts in einen Bildschirmpunkt verwandeln. Dazu kommt eine Hilfsfunktion vieleck, die aus einer Liste von Ecken eine geschlossene Fläche baut. Beides kommt vor den Kommentar // Alle Farben an einem Ort:

src/main.rsvor // Alle Farben an einem Ort
// Rechnet zwischen Brett und Bildschirm hin und her
struct Iso {
    ursprung: Point,
}

impl Iso {
    fn neu(groesse: Size) -> Iso {
        // Die Mitte des Bretts soll in der Mitte der Leinwand liegen
        let mitte_s = SPALTEN as f32 / 2.0;
        let mitte_r = REIHEN as f32 / 2.0;
        let x = groesse.width / 2.0 - (mitte_s - mitte_r) * HALBE_BREITE;
        let y = groesse.height / 2.0 - (mitte_s + mitte_r) * HALBE_HOEHE + 10.0;
        Iso {
            ursprung: Point::new(x, y),
        }
    }

    // Hinweg: Brett (Spalte, Reihe, Höhe) -> Bildschirm
    fn punkt(&self, s: f32, r: f32, z: f32) -> Point {
        Point::new(
            self.ursprung.x + (s - r) * HALBE_BREITE,
            self.ursprung.y + (s + r) * HALBE_HOEHE - z,
        )
    }
}

// Baut aus einer Liste von Ecken eine geschlossene Fläche
fn vieleck(ecken: &[Point]) -> Path {
    Path::new(|p| {
        p.move_to(ecken[0]);
        for ecke in &ecken[1..] {
            p.line_to(*ecke);
        }
        p.close();
    })
}
// Alle Farben an einem Ort
  • Iso::neu rechnet den Ursprung so aus, dass die Mitte des Bretts (Spalte 6, Reihe 4,5) genau in der Mitte der Leinwand landet. Dazu nehmen wir die Formel einfach rückwärts: Mitte der Leinwand minus der Weg vom Ursprung zur Brettmitte. Die zusätzlichen 10 Pixel schieben das Brett ein Stück nach unten, damit oben Platz für die Kopfzeile bleibt.
  • punkt ist unsere Formel von oben, einmal in Rust.
  • vieleck nimmt die erste Ecke als Startpunkt (move_to), zieht zu allen weiteren Ecken eine Linie (line_to) und schließt die Form wieder (close). &ecken[1..] heißt „alle Ecken ab der zweiten“.

Jetzt legen wir in draw alle 108 Felder. Jedes Feld wird eine Raute, die wir an allen Seiten um ein Zehntel verkleinern. So bleibt zwischen den Feldern eine Fuge:

src/main.rsin impl canvas::Program, Funktion draw
        let mut frame = Frame::new(renderer, bounds.size());
        let farben = Farben::tag();        let iso = Iso::neu(bounds.size());
        self.zeichne_himmel(&mut frame, &farben, bounds.size());
        for reihe in 0..REIHEN {
            for spalte in 0..SPALTEN {
                let s = spalte as f32;
                let r = reihe as f32;
                let ecken = [
                    iso.punkt(s + 0.1, r + 0.1, 0.0),
                    iso.punkt(s + 0.9, r + 0.1, 0.0),
                    iso.punkt(s + 0.9, r + 0.9, 0.0),
                    iso.punkt(s + 0.1, r + 0.9, 0.0),
                ];
                frame.fill(&vieleck(&ecken), farben.rasen);
            }
        }
        vec![frame.into_geometry()]

Zum Schluss braucht die Farbpalette noch ein Grün für den Rasen. Neue Felder in Farben kommen immer an zwei Stellen dazu: einmal oben in der Beschreibung der Struktur, einmal unten in tag(), wo der Wert festgelegt wird:

src/main.rsin struct Farben und in fn tag()
    himmel_unten: Color,    rasen: Color,}    ⋮            himmel_unten: Color::from_rgb8(0xe9, 0xf2, 0xf7),            rasen: Color::from_rgb8(0x9c, 0xcb, 0x6b),        }
Worauf du achten musst Vergisst du eine der beiden Stellen, meldet sich Rust sofort: Entweder fehlt das Feld in der Struktur (struct Farben has no field named rasen), oder es fehlt der Wert (missing field rasen). Diese Meldungen wirst du in dieser Etappe noch öfter provozieren können. Sie sind harmlos: einfach die zweite Stelle nachtragen.
Probelauf Speichern, dann im Terminal:
cargo run
Unter der Sonne liegt jetzt ein schräges Gitter aus 108 grünen Rauten. Noch flach wie ein Blatt Papier, aber die Perspektive stimmt schon.
Ein schräges Gitter aus grünen Rauten unter einem blauen Himmel mit Sonne
Der Zwischenstand nach Schritt 4: 108 Rauten in Isometrie.
Schritt 5

Der Quader

Flache Rauten sehen noch nach Papier aus. Räumlich wird es erst, wenn Dinge Dicke bekommen. Und hier kommt der wichtigste Baustein des ganzen Projekts: der Quader, also ein Kasten mit Länge, Breite und Höhe. Aus Quadern bauen wir alles: den Sockel des Spielbretts, die Bordsteine der Parzellen und später jedes Haus und jeden Hotelturm.

Von einem Quader sehen wir in unserer Ansicht immer genau drei Flächen: den Deckel oben, die Seite links vorne und die Seite rechts vorne. Die drei hinteren Flächen sind vom Quader selbst verdeckt, die zeichnen wir gar nicht erst. Damit das Auge die drei Flächen als Kasten erkennt, geben wir ihnen unterschiedliche Helligkeit, als käme das Licht von links: Der Deckel ist am hellsten, die linke Seite etwas dunkler, die rechte am dunkelsten. Das ist schon der ganze Trick hinter 2,5D-Grafik.

Für die feine Lichtkante brauchen wir gleich Stroke, also das Werkzeug für Linien:

src/main.rsganz oben, zweite use-Zeile
use iced::mouse;
use iced::widget::canvas;use iced::widget::canvas::{Frame, Path, gradient};use iced::widget::canvas::{Frame, Path, Stroke, gradient};

Jetzt der Quader selbst. Er beschreibt sich durch zwei gegenüberliegende Ecken auf dem Brett, (s0, r0) hinten und (s1, r1) vorne, dazu eine Unterkante z0, eine Oberkante z1 und eine Farbe. Das Ganze kommt vor vieleck:

src/main.rsvor // Baut aus einer Liste von Ecken …
// Ein Quader: von (s0, r0) bis (s1, r1) auf dem Brett, von z0 bis z1 in der Höhe
struct Quader {
    s0: f32,
    r0: f32,
    s1: f32,
    r1: f32,
    z0: f32,
    z1: f32,
    farbe: Color,
}

impl Quader {
    fn oberseite(&self, iso: &Iso) -> [Point; 4] {
        [
            iso.punkt(self.s0, self.r0, self.z1),
            iso.punkt(self.s1, self.r0, self.z1),
            iso.punkt(self.s1, self.r1, self.z1),
            iso.punkt(self.s0, self.r1, self.z1),
        ]
    }
}

fn zeichne_quader(frame: &mut Frame, iso: &Iso, q: &Quader) {
    let links = [
        iso.punkt(q.s0, q.r1, q.z1),
        iso.punkt(q.s1, q.r1, q.z1),
        iso.punkt(q.s1, q.r1, q.z0),
        iso.punkt(q.s0, q.r1, q.z0),
    ];
    let rechts = [
        iso.punkt(q.s1, q.r0, q.z1),
        iso.punkt(q.s1, q.r1, q.z1),
        iso.punkt(q.s1, q.r1, q.z0),
        iso.punkt(q.s1, q.r0, q.z0),
    ];
    let oben = q.oberseite(iso);

    // Das Licht kommt von links: links mittel, rechts dunkel, oben am hellsten
    frame.fill(&vieleck(&links), abdunkeln(q.farbe, 0.78));
    frame.fill(&vieleck(&rechts), abdunkeln(q.farbe, 0.58));
    frame.fill(&vieleck(&oben), q.farbe);

    // Eine feine Lichtkante an den beiden vorderen Oberkanten
    let kante = Path::new(|p| {
        p.move_to(oben[3]);
        p.line_to(oben[2]);
        p.line_to(oben[1]);
    });
    frame.stroke(
        &kante,
        Stroke::default()
            .with_color(aufhellen(q.farbe, 0.35))
            .with_width(1.0),
    );
}
// Baut aus einer Liste von Ecken eine geschlossene Fläche
  • oberseite liefert die vier Ecken des Deckels, alle auf der Höhe z1. Der Rückgabetyp [Point; 4] ist ein Array: eine Liste mit fester Länge, hier genau vier Punkte. Wir brauchen den Deckel gleich an mehreren Stellen, deshalb bekommt er eine eigene Methode.
  • Die linke Seite liegt an der vorderen Kante r1 und reicht von oben (z1) bis unten (z0), die rechte Seite genauso an der Kante s1. Schau dir die Ecken an: Bei links steht überall q.r1, bei rechts überall q.s1.
  • Die Reihenfolge ist wichtig: erst die Seiten, dann der Deckel. Was später gezeichnet wird, liegt obendrauf, genau wie bei Farbe auf einer Leinwand.
  • Die Lichtkante ist eine dünne, hellere Linie an den beiden vorderen Oberkanten. Kaum sichtbar, aber sie lässt die Kanten scharf und hochwertig wirken.

Die beiden Farbhelfer schieben wir vor die Farbpalette. abdunkeln multipliziert jeden Farbanteil mit einem Faktor, 0.58 macht die Farbe also deutlich dunkler. aufhellen mischt einen Anteil Weiß dazu. Farben rechnet iced intern mit Werten von 0.0 bis 1.0, deshalb funktioniert das so einfach:

src/main.rsvor // Alle Farben an einem Ort
fn abdunkeln(farbe: Color, faktor: f32) -> Color {
    Color {
        r: farbe.r * faktor,
        g: farbe.g * faktor,
        b: farbe.b * faktor,
        a: farbe.a,
    }
}

fn aufhellen(farbe: Color, anteil: f32) -> Color {
    Color {
        r: farbe.r + (1.0 - farbe.r) * anteil,
        g: farbe.g + (1.0 - farbe.g) * anteil,
        b: farbe.b + (1.0 - farbe.b) * anteil,
        a: farbe.a,
    }
}
// Alle Farben an einem Ort

Unser erster richtiger Quader wird der Sockel, auf dem das ganze Brett steht: etwas größer als die 12 mal 9 Felder, 22 Pixel dick, in Asphaltgrau. Darunter legen wir noch einen halbdurchsichtigen Schatten, ein Stück tiefer, als würde das Brett über dem Tisch schweben. Die Funktion gehört nicht zum Spiel, sondern ist ein freier Helfer, deshalb steht sie außerhalb von impl Bauloewe, direkt vor Iso:

src/main.rsvor // Rechnet zwischen Brett und Bildschirm …
fn zeichne_sockel(frame: &mut Frame, iso: &Iso, farben: &Farben) {
    let rand = 0.35;
    let (s0, r0) = (-rand, -rand);
    let (s1, r1) = (SPALTEN as f32 + rand, REIHEN as f32 + rand);

    // Ein weicher Schatten unter dem ganzen Brett
    let schatten = [
        iso.punkt(s0, r0, -34.0),
        iso.punkt(s1, r0, -34.0),
        iso.punkt(s1, r1, -34.0),
        iso.punkt(s0, r1, -34.0),
    ];
    frame.fill(&vieleck(&schatten), Color::from_rgba(0.0, 0.0, 0.0, 0.18));

    let sockel = Quader {
        s0,
        r0,
        s1,
        r1,
        z0: -22.0,
        z1: 0.0,
        farbe: farben.strasse,
    };
    zeichne_quader(frame, iso, &sockel);
}
// Rechnet zwischen Brett und Bildschirm hin und her

Beachte let (s0, r0) = (-rand, -rand);: So legt man in Rust zwei Namen in einer Zeile an. Und Quader { s0, r0, s1, r1, … } ist eine Abkürzung: Heißt eine Variable genauso wie das Feld, reicht der Name einmal. In draw rufen wir den Sockel direkt nach dem Himmel auf, also vor den Rauten, damit die Rauten obendrauf liegen:

src/main.rsin draw, unter zeichne_himmel
        self.zeichne_himmel(&mut frame, &farben, bounds.size());        zeichne_sockel(&mut frame, &iso, &farben);

Und die Palette bekommt ihr Asphaltgrau:

src/main.rsin struct Farben und in fn tag()
    himmel_unten: Color,    strasse: Color,    rasen: Color,    ⋮            himmel_unten: Color::from_rgb8(0xe9, 0xf2, 0xf7),            strasse: Color::from_rgb8(0x5a, 0x62, 0x70),            rasen: Color::from_rgb8(0x9c, 0xcb, 0x6b),
Probelauf Speichern, dann im Terminal:
cargo run
Die Rauten liegen jetzt auf einem dicken, grauen Sockel, der vorne links und vorne rechts seine Kanten zeigt. Zwischen den Rauten schimmert der Asphalt durch: Das sind unsere Straßen.
Schritt 6

Parzellen mit Namen

Jetzt verwandeln wir die flachen Rauten in richtige Parzellen: ein heller Bordstein als flacher Quader, darauf der Rasen, und in der Mitte der Name des Feldes, so wie auf dem echten Spielplan. Dafür brauchen wir ein paar neue Dinge aus iced, vor allem Text für die Beschriftung und Font für die Schrift:

src/main.rsganz oben, die use-Zeilen 2 bis 4
use iced::mouse;use iced::widget::canvas;
use iced::widget::canvas::{Frame, Path, Stroke, gradient};
use iced::{Color, Element, Fill, Point, Rectangle, Renderer, Size, Theme};use iced::widget::canvas::{Frame, Path, Stroke, Text, gradient};
use iced::widget::{canvas, text};
use iced::{Color, Element, Fill, Font, Point, Rectangle, Renderer, Size, Theme, alignment};

Die Dicke der Bordsteine legen wir als Konstante fest:

src/main.rsunter den Konstanten für die Feldgröße
const HALBE_HOEHE: f32 = 20.0;
// Höhen in Pixeln
const PLATTE: f32 = 4.0;
fn main() -> iced::Result {

Bisher war ein Feld nur ein Paar aus zwei Zahlen in einer Schleife. Ab jetzt werden Felder im ganzen Spiel wichtig: Man legt Plättchen darauf, klickt sie an, baut Hotels. Deshalb bekommen sie einen eigenen Typ. Er kommt direkt unter struct Bauloewe:

src/main.rsunter struct Bauloewe {}
#[derive(Default)]
struct Bauloewe {}
#[derive(Debug, Clone, Copy, PartialEq)]
struct Feld {
    spalte: usize,
    reihe: usize,
}

impl Feld {
    // Der Name wie auf dem Spielplan: "1A" bis "12I"
    fn name(self) -> String {
        let buchstabe = (b'A' + self.reihe as u8) as char;
        format!("{}{}", self.spalte + 1, buchstabe)
    }
}
#[derive(Debug, Clone)]
enum Message {}
  • derive lässt Rust Fähigkeiten automatisch dazubauen. Copy bedeutet: Ein Feld ist so klein, dass Rust es beim Weitergeben einfach kopieren darf, ohne dass wir über Ausleihen nachdenken müssen. PartialEq erlaubt Vergleiche mit ==. Beides brauchen wir im nächsten Schritt.
  • name baut den Namen wie auf dem Spielplan. Die Spalte ist einfach die Zahl plus eins (wir zählen ab 0, Menschen ab 1). Für den Buchstaben nutzen wir, dass Buchstaben im Computer Zahlen sind: b'A' ist die Zahl von „A“, plus 2 ergibt die Zahl von „C“, und as char macht daraus wieder einen Buchstaben.

Jetzt zu einer Frage, die bei jeder Iso-Grafik auftaucht: In welcher Reihenfolge zeichnen wir? Maler malen erst den Hintergrund und dann den Vordergrund darüber. Genau so machen wir es auch, man nennt das sogar den Maler-Algorithmus. Welches Feld weiter hinten liegt, verrät uns die Formel von Schritt 4: Je kleiner s + r, desto weiter oben auf dem Bildschirm, also weiter hinten. Wir sammeln also alle Felder in einer Liste, sortieren sie nach s + r und zeichnen sie dann der Reihe nach. Die alte Doppelschleife in draw ersetzen wir komplett:

src/main.rsin draw, die komplette Doppelschleife ersetzen
        zeichne_sockel(&mut frame, &iso, &farben);
        for reihe in 0..REIHEN {
            for spalte in 0..SPALTEN {
                let s = spalte as f32;
                let r = reihe as f32;
                let ecken = [
                    iso.punkt(s + 0.1, r + 0.1, 0.0),
                    iso.punkt(s + 0.9, r + 0.1, 0.0),
                    iso.punkt(s + 0.9, r + 0.9, 0.0),
                    iso.punkt(s + 0.1, r + 0.9, 0.0),
                ];
                frame.fill(&vieleck(&ecken), farben.rasen);
            }
        }        // Von hinten nach vorne zeichnen, damit Vorderes Hinteres verdeckt
        let mut felder = Vec::new();
        for reihe in 0..REIHEN {
            for spalte in 0..SPALTEN {
                felder.push(Feld { spalte, reihe });
            }
        }
        felder.sort_by_key(|feld| feld.spalte + feld.reihe);

        for feld in felder {
            self.zeichne_parzelle(&mut frame, &iso, &farben, feld);
        }
        vec![frame.into_geometry()]

Vec::new() legt eine leere, wachsende Liste an, push hängt ein Feld hinten an. sort_by_key sortiert die Liste nach dem Wert, den die kleine Closure für jedes Feld ausrechnet, hier eben spalte + reihe. Und for feld in felder geht die sortierte Liste von vorne bis hinten durch.

Fehlt noch zeichne_parzelle. Sie kommt in den zweiten impl Bauloewe-Block, unter zeichne_himmel:

src/main.rsim zweiten impl Bauloewe, unter fn zeichne_himmel
            Color::from_rgb8(0xff, 0xf6, 0xd8),
        );
    }
    fn zeichne_parzelle(&self, frame: &mut Frame, iso: &Iso, farben: &Farben, feld: Feld) {
        let s = feld.spalte as f32;
        let r = feld.reihe as f32;
        let hoehe = PLATTE;

        // Der Bordstein ist ein flacher Quader
        let rand = 0.1;
        let bordstein = Quader {
            s0: s + rand,
            r0: r + rand,
            s1: s + 1.0 - rand,
            r1: r + 1.0 - rand,
            z0: 0.0,
            z1: hoehe,
            farbe: farben.stein,
        };
        zeichne_quader(frame, iso, &bordstein);

        // Der Rasen liegt als Fläche oben auf dem Bordstein
        let innen = rand + 0.04;
        let rasen = [
            iso.punkt(s + innen, r + innen, hoehe),
            iso.punkt(s + 1.0 - innen, r + innen, hoehe),
            iso.punkt(s + 1.0 - innen, r + 1.0 - innen, hoehe),
            iso.punkt(s + innen, r + 1.0 - innen, hoehe),
        ];
        frame.fill(&vieleck(&rasen), farben.rasen);

        let schild = Text {
            content: feld.name(),
            position: iso.punkt(s + 0.5, r + 0.5, hoehe),
            color: farben.beschriftung,
            size: 11.0.into(),
            font: Font::MONOSPACE,
            align_x: text::Alignment::Center,
            align_y: alignment::Vertical::Center,
            ..Text::default()
        };
        // Als Formen zeichnen, damit Häuser davor die Schrift verdecken
        schild.draw_with(|form, farbe| frame.fill(&form, farbe));
    }}
  • Der Bordstein ist ein flacher Quader, an jeder Seite um rand kleiner als das Feld. So bleibt zwischen zwei Parzellen ein Streifen Asphalt frei: die Straße.
  • Der Rasen ist kein Quader, sondern nur eine Fläche, die wir oben auf den Bordstein legen, noch ein Stück kleiner. Vom Bordstein bleibt dadurch ein heller Rand sichtbar.
  • Das Namensschild ist ein Text mitten auf dem Rasen. ..Text::default() heißt: Alle Einstellungen, die wir nicht selbst angeben, bekommen ihren Standardwert.
Worauf du achten musst Die letzte Zeile sieht umständlich aus. Warum nicht einfach frame.fill_text(schild)? Weil iced Texte auf einer Leinwand normalerweise ganz zum Schluss zeichnet, über allen Flächen. Für unser Brett wäre das fatal: Sobald in Schritt 8 Häuser stehen, würden die Feldnamen dahinter durch die Hauswände hindurchscheinen. draw_with verwandelt den Text deshalb in ganz normale Formen, einen Umriss pro Buchstabe, und die zeichnen wir mit frame.fill an genau der richtigen Stelle der Reihenfolge.

Zum Schluss zwei neue Farben: der helle Stein für den Bordstein und ein halbdurchsichtiges Dunkelgrün für die Schrift:

src/main.rsin struct Farben und in fn tag()
    strasse: Color,    stein: Color,    rasen: Color,    beschriftung: Color,}    ⋮            strasse: Color::from_rgb8(0x5a, 0x62, 0x70),            stein: Color::from_rgb8(0xd9, 0xd4, 0xc7),            rasen: Color::from_rgb8(0x9c, 0xcb, 0x6b),            beschriftung: Color::from_rgba8(0x1e, 0x32, 0x14, 0.55),        }
Probelauf Speichern, dann im Terminal:
cargo run
Das Brett hat jetzt 108 Parzellen mit hellem Bordstein, grünem Rasen und Namen von 1A bis 12I. Dazwischen verlaufen die Straßen. Schau dir die Ecke oben an: Dort liegt 1A, ganz vorne unten liegt 12I.
Das isometrische Spielbrett mit grauem Sockel und 108 beschrifteten grünen Parzellen mit hellem Bordstein
Der Zwischenstand nach Schritt 6: das fertige, aber noch leere Baugrundstück.
Schritt 7

Die Maus

Unser Brett ist hübsch, aber taub. Jetzt soll es merken, wo die Maus ist: Das Feld unter der Maus hebt sich ein Stück an, leuchtet heller und bekommt einen orangen Rand. Und oben im Fenster erscheint eine Kopfzeile, die den Namen des Feldes anzeigt.

Die Hauptarbeit ist dabei der Rückweg. Bisher haben wir immer von Brett zu Bildschirm gerechnet. Die Maus liefert uns aber einen Bildschirmpunkt, und wir wollen wissen, welches Feld darunter liegt. Wir brauchen die Formel von Schritt 4 also rückwärts. Zieht man den Ursprung ab und teilt durch 40 beziehungsweise 20, bleibt:

dx = s − r
dy = s + r

Zwei Gleichungen, zwei Unbekannte. Addiert man beide, fällt r weg: dx + dy = 2s, also s = (dy + dx) / 2. Zieht man sie voneinander ab, fällt s weg: r = (dy − dx) / 2. Das ist schon alles. Als Methode von Iso kommt das direkt unter punkt:

src/main.rsin impl Iso, unter fn punkt
            self.ursprung.y + (s + r) * HALBE_HOEHE - z,
        )
    }
    // Rückweg: Bildschirm -> Feld auf dem Brett
    fn feld_unter(&self, punkt: Point) -> Option<Feld> {
        let dx = (punkt.x - self.ursprung.x) / HALBE_BREITE;
        let dy = (punkt.y - self.ursprung.y) / HALBE_HOEHE;
        let s = (dy + dx) / 2.0;
        let r = (dy - dx) / 2.0;
        if s < 0.0 || r < 0.0 {
            return None;
        }
        let spalte = s as usize;
        let reihe = r as usize;
        if spalte < SPALTEN && reihe < REIHEN {
            Some(Feld { spalte, reihe })
        } else {
            None
        }
    }}

Das Ergebnis sind Kommazahlen, etwa s = 6.7. Mit as usize schneiden wir die Nachkommastellen ab und bekommen die Feldnummer 6. Weil die Maus auch neben dem Brett stehen kann, liefert die Methode ein Option<Feld> zurück: Some(feld), wenn ein Feld getroffen wurde, sonst None. Deshalb prüfen wir erst auf negative Werte (links oder über dem Brett) und dann, ob die Nummern noch auf dem Brett liegen.

Das Spiel muss sich jetzt etwas merken, nämlich welches Feld unter der Maus liegt. Und es bekommt seinen ersten Brief:

src/main.rsstruct Bauloewe
struct Bauloewe {}struct Bauloewe {
    maus_feld: Option<Feld>,
}
src/main.rsenum Message und fn update
enum Message {}enum Message {
    MausUeber(Option<Feld>),
}    ⋮    fn update(&mut self, message: Message) {        match message {}        match message {
            Message::MausUeber(feld) => self.maus_feld = feld,
        }    }

Wer schickt den Brief? Die Leinwand. Das Program-Trait hat neben draw eine zweite Methode, update, die iced bei jedem Ereignis auf der Leinwand aufruft: Maus bewegt, Taste gedrückt, und so weiter. Sie kommt über draw:

src/main.rsin impl canvas::Program, über fn draw
    type State = ();
    fn update(
        &self,
        _state: &mut Self::State,
        event: &canvas::Event,
        bounds: Rectangle,
        cursor: mouse::Cursor,
    ) -> Option<canvas::Action<Message>> {
        let iso = Iso::neu(bounds.size());
        let feld = cursor
            .position_in(bounds)
            .and_then(|punkt| iso.feld_unter(punkt));

        match event {
            canvas::Event::Mouse(_) if feld != self.maus_feld => {
                Some(canvas::Action::publish(Message::MausUeber(feld)))
            }
            _ => None,
        }
    }
    fn draw(
  • cursor.position_in(bounds) liefert die Mausposition relativ zur Leinwand, oder None, wenn die Maus außerhalb ist. and_then rechnet nur weiter, wenn es eine Position gibt, und gibt sie dann an feld_unter.
  • canvas::Event::Mouse(_) if feld != self.maus_feld ist ein match-Zweig mit Bedingung. Er greift bei jedem Mausereignis, aber nur, wenn sich das Feld geändert hat. Die Maus liefert beim Bewegen dutzende Ereignisse pro Sekunde. Würden wir jedes Mal einen Brief schicken, hätte update viel Arbeit für nichts.
  • canvas::Action::publish wirft den Brief in den Briefkasten. Danach ruft iced unser update auf und zeichnet neu.

Eine kleine Aufmerksamkeit für die Maus kommt unter draw, noch im selben Block: Über einem Feld wird der Mauszeiger zur Hand, wie bei einem Link.

src/main.rsin impl canvas::Program, unter fn draw
        vec![frame.into_geometry()]
    }
    fn mouse_interaction(
        &self,
        _state: &Self::State,
        _bounds: Rectangle,
        _cursor: mouse::Cursor,
    ) -> mouse::Interaction {
        if self.maus_feld.is_some() {
            mouse::Interaction::Pointer
        } else {
            mouse::Interaction::default()
        }
    }}

Jetzt reagiert die Parzelle. In zeichne_parzelle fragen wir, ob sie gerade unter der Maus liegt. Wenn ja, ist ihr Bordstein höher. Das nennt man einen if-Ausdruck: Das if liefert einen Wert, den wir direkt in hoehe speichern.

src/main.rsin fn zeichne_parzelle, oben
        let r = feld.reihe as f32;        let hoehe = PLATTE;        let unter_maus = self.maus_feld == Some(feld);
        let hoehe = if unter_maus {
            PLATTE + ANGEHOBEN
        } else {
            PLATTE
        };
        // Der Bordstein ist ein flacher Quader

Weiter unten bekommt der Rasen seine hellere Farbe und die Parzelle ihren orangen Rahmen. Der Rahmen läuft um die Oberseite des Bordsteins, und genau dafür haben wir in Schritt 5 oberseite als eigene Methode gebaut:

src/main.rsin fn zeichne_parzelle, unter let rasen = [ … ];
            iso.punkt(s + innen, r + 1.0 - innen, hoehe),
        ];        frame.fill(&vieleck(&rasen), farben.rasen);        let gruen = if unter_maus {
            farben.rasen_hell
        } else {
            farben.rasen
        };
        frame.fill(&vieleck(&rasen), gruen);

        if unter_maus {
            let umriss = vieleck(&bordstein.oberseite(iso));
            frame.stroke(
                &umriss,
                Stroke::default().with_color(farben.akzent).with_width(2.0),
            );
        }
        let schild = Text {

Die Kopfzeile bauen wir in view aus normalen iced-Widgets: fetter Titel links, Statustext rechts, dazwischen ein dehnbarer Leerraum (space::horizontal()), der die beiden auseinanderschiebt. Damit die Kopfzeile nicht über dem Brett einen eigenen Streifen belegt, legen wir sie mit stack! über die Leinwand, wie eine Folie auf dem Overheadprojektor. So läuft der Himmel bis an den oberen Fensterrand.

src/main.rsin impl Bauloewe, Funktion view
    fn view(&self) -> Element<'_, Message> {        canvas(self).width(Fill).height(Fill).into()        let farben = Farben::tag();

        let titel = text("BAULÖWE")
            .size(26)
            .font(Font {
                weight: font::Weight::Bold,
                ..Font::DEFAULT
            })
            .color(farben.schrift);

        let hinweis = match self.maus_feld {
            Some(feld) => format!("Feld {}", feld.name()),
            None => String::from("Fahr mit der Maus über das Baugrundstück"),
        };
        let status = text(hinweis).size(15).color(farben.schrift);

        let kopfzeile = row![titel, space::horizontal(), status]
            .spacing(20)
            .padding([14, 24])
            .align_y(alignment::Vertical::Center);

        let brett = canvas(self).width(Fill).height(Fill);

        // Die Kopfzeile liegt wie eine Folie über dem Brett
        stack![brett, kopfzeile].into()    }

Dafür brauchen wir die Widgets row, space und stack sowie font für die fette Schrift:

src/main.rsganz oben, die use-Zeilen 3 und 4
use iced::widget::{canvas, text};
use iced::{Color, Element, Fill, Font, Point, Rectangle, Renderer, Size, Theme, alignment};use iced::widget::{canvas, row, space, stack, text};
use iced::{Color, Element, Fill, Font, Point, Rectangle, Renderer, Size, Theme, alignment, font};

Dazu die Höhe fürs Anheben und drei neue Farben:

src/main.rsunter const PLATTE
const PLATTE: f32 = 4.0;const ANGEHOBEN: f32 = 9.0;
src/main.rsin struct Farben und in fn tag()
    rasen: Color,    rasen_hell: Color,    beschriftung: Color,    akzent: Color,
    schrift: Color,}    ⋮            rasen: Color::from_rgb8(0x9c, 0xcb, 0x6b),            rasen_hell: Color::from_rgb8(0xc4, 0xe6, 0x8f),            beschriftung: Color::from_rgba8(0x1e, 0x32, 0x14, 0.55),            akzent: Color::from_rgb8(0xce, 0x5a, 0x1e),
            schrift: Color::from_rgb8(0x1c, 0x2a, 0x3a),        }
Probelauf Speichern, dann im Terminal:
cargo run
Fahr mit der Maus übers Brett. Das Feld darunter springt hoch, wird hellgrün und bekommt einen orangen Rahmen, der Mauszeiger wird zur Hand, und oben rechts steht zum Beispiel „Feld 7C“. Neben dem Brett kehrt alles zurück, und die Kopfzeile lädt dich wieder ein.
Schritt 8

Der erste Rohbau

Ein Baugrundstück ohne Haus ist traurig. Also darf man jetzt bauen: Ein Klick auf eine Parzelle stellt dort einen dreistöckigen Rohbau hin, mit Fenstern, Flachdach und Schatten. In den späteren Etappen werden daraus die Hotels der Ketten, jede Kette mit eigenem Baustil. Heute legen wir das Handwerkszeug dafür an.

Das Spiel muss sich merken, wo schon gebaut wurde. Dafür reicht eine Liste von Feldern. Dazu kommen ein neuer Brief und die Höhe eines Stockwerks:

src/main.rsunter const ANGEHOBEN
const ANGEHOBEN: f32 = 9.0;const STOCKWERK: f32 = 13.0;
src/main.rsstruct Bauloewe und enum Message
struct Bauloewe {
    maus_feld: Option<Feld>,    rohbauten: Vec<Feld>,}    ⋮enum Message {
    MausUeber(Option<Feld>),    Geklickt(Feld),}

Beim Brief Geklickt kommt das Feld auf die Liste, aber nur, wenn dort noch nichts steht. contains prüft, ob ein Wert schon in der Liste ist. Das funktioniert nur, weil wir Feld in Schritt 6 mit PartialEq vergleichbar gemacht haben:

src/main.rsin fn update
            Message::MausUeber(feld) => self.maus_feld = feld,            Message::Geklickt(feld) => {
                if !self.rohbauten.contains(&feld) {
                    self.rohbauten.push(feld);
                }
            }        }

Den Brief schickt wieder die Leinwand, diesmal bei einem Klick mit der linken Maustaste. Der neue Zweig kommt vor den Zweig für die Mausbewegung, weil match die Zweige von oben nach unten prüft und der erste passende gewinnt. Ein Klick ist ja auch ein Mausereignis und würde sonst im unteren Zweig landen:

src/main.rsin impl canvas::Program, fn update
        match event {            canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
                feld.map(|feld| canvas::Action::publish(Message::Geklickt(feld)))
            }            canvas::Event::Mouse(_) if feld != self.maus_feld => {

feld.map(…) ist ein praktischer Trick mit Option: Ist ein Feld da, wird daraus ein Brief. Ist keins da (Klick neben das Brett), bleibt es None, und es passiert nichts.

In zeichne_parzelle entscheiden wir jetzt: Steht auf dem Feld ein Rohbau, zeichnen wir das Haus, sonst wie bisher das Namensschild. Das Schild wandert dafür in einen else-Zweig und rückt eine Stufe nach rechts:

src/main.rsin fn zeichne_parzelle, ganz unten
                Stroke::default().with_color(farben.akzent).with_width(2.0),
            );
        }
        let schild = Text {
            content: feld.name(),
            position: iso.punkt(s + 0.5, r + 0.5, hoehe),
            color: farben.beschriftung,
            size: 11.0.into(),
            font: Font::MONOSPACE,
            align_x: text::Alignment::Center,
            align_y: alignment::Vertical::Center,
            ..Text::default()
        };
        // Als Formen zeichnen, damit Häuser davor die Schrift verdecken
        schild.draw_with(|form, farbe| frame.fill(&form, farbe));        if self.rohbauten.contains(&feld) {
            self.zeichne_rohbau(frame, iso, farben, feld, hoehe);
        } else {
            let schild = Text {
                content: feld.name(),
                position: iso.punkt(s + 0.5, r + 0.5, hoehe),
                color: farben.beschriftung,
                size: 11.0.into(),
                font: Font::MONOSPACE,
                align_x: text::Alignment::Center,
                align_y: alignment::Vertical::Center,
                ..Text::default()
            };
            // Als Formen zeichnen, damit Häuser davor die Schrift verdecken
            schild.draw_with(|form, farbe| frame.fill(&form, farbe));
        }    }

Und nun das Haus. Die Funktion ist länger, aber sie besteht nur aus Bausteinen, die du schon kennst. Sie kommt direkt unter zeichne_parzelle, noch in denselben impl-Block:

src/main.rsim zweiten impl Bauloewe, unter fn zeichne_parzelle
            schild.draw_with(|form, farbe| frame.fill(&form, farbe));
        }
    }
    fn zeichne_rohbau(
        &self,
        frame: &mut Frame,
        iso: &Iso,
        farben: &Farben,
        feld: Feld,
        boden: f32,
    ) {
        let s = feld.spalte as f32;
        let r = feld.reihe as f32;
        let stockwerke = 3;
        let haus = Quader {
            s0: s + 0.2,
            r0: r + 0.2,
            s1: s + 0.8,
            r1: r + 0.8,
            z0: boden,
            z1: boden + stockwerke as f32 * STOCKWERK,
            farbe: farben.beton,
        };

        // Der Schatten fällt nach rechts auf den Rasen
        let schatten = [
            iso.punkt(haus.s1, haus.r0, boden),
            iso.punkt(haus.s1 + 0.12, haus.r0 + 0.08, boden),
            iso.punkt(haus.s1 + 0.12, haus.r1, boden),
            iso.punkt(haus.s1, haus.r1, boden),
        ];
        frame.fill(&vieleck(&schatten), Color::from_rgba(0.0, 0.0, 0.0, 0.25));

        zeichne_quader(frame, iso, &haus);

        // Fenster: pro Stockwerk zwei auf jeder sichtbaren Seite
        for stock in 0..stockwerke {
            let unten = boden + stock as f32 * STOCKWERK + 4.0;
            let oben = unten + 6.0;
            for nr in 0..2 {
                let a = 0.08 + nr as f32 * 0.27;
                let b = a + 0.17;
                let links = [
                    iso.punkt(haus.s0 + a, haus.r1, oben),
                    iso.punkt(haus.s0 + b, haus.r1, oben),
                    iso.punkt(haus.s0 + b, haus.r1, unten),
                    iso.punkt(haus.s0 + a, haus.r1, unten),
                ];
                let rechts = [
                    iso.punkt(haus.s1, haus.r0 + a, oben),
                    iso.punkt(haus.s1, haus.r0 + b, oben),
                    iso.punkt(haus.s1, haus.r0 + b, unten),
                    iso.punkt(haus.s1, haus.r0 + a, unten),
                ];
                for (seite, ecken) in [(0, links), (1, rechts)] {
                    let farbe = if seite == 0 {
                        farben.fenster
                    } else {
                        abdunkeln(farben.fenster, 0.8)
                    };
                    frame.fill(&vieleck(&ecken), farbe);
                }
            }
        }

        // Das Flachdach bekommt einen Rand
        let innen = 0.05;
        let dach = [
            iso.punkt(haus.s0 + innen, haus.r0 + innen, haus.z1),
            iso.punkt(haus.s1 - innen, haus.r0 + innen, haus.z1),
            iso.punkt(haus.s1 - innen, haus.r1 - innen, haus.z1),
            iso.punkt(haus.s0 + innen, haus.r1 - innen, haus.z1),
        ];
        frame.fill(&vieleck(&dach), abdunkeln(farben.beton, 0.85));
    }}

Gehen wir es in der Reihenfolge durch, in der gezeichnet wird:

  • Der Grundriss. Das Haus ist ein Quader, der auf jeder Seite 0,2 Felder Abstand zum Rand hält, und dessen Unterkante auf dem Bordstein steht (boden). Die Oberkante ergibt sich aus drei Stockwerken mal 13 Pixel.
  • Der Schatten kommt zuerst, damit das Haus ihn verdeckt. Das Licht fällt von links, also liegt der Schatten rechts: ein schmales, dunkles Viereck, das an der rechten Hauswand beginnt und ein Stück auf den Rasen reicht.
  • Die Fenster. Zwei Schleifen ineinander: für jedes Stockwerk und darin für zwei Fenster nebeneinander. a und b sind Anfang und Ende eines Fensters entlang der Wand: 0,08 Rand, 0,17 Fenster, 0,10 Mauer, 0,17 Fenster, 0,08 Rand, zusammen genau die 0,6 Felder Hausbreite. Jedes Fenster ist ein Viereck, das flach auf der Wand liegt. Auf der linken Wand ändert sich dafür nur s, auf der rechten nur r.
  • Eine Schleife über ein Array von Paaren. for (seite, ecken) in [(0, links), (1, rechts)] geht zwei Paare durch und packt jedes gleich in zwei Namen aus. So brauchen wir den Code für die Fensterfarbe nur einmal. Auf der rechten, dunkleren Seite dunkeln wir auch das Glas etwas ab.
  • Das Dach bekommt eine zweite, etwas kleinere und dunklere Fläche. Außen bleibt ein heller Rand stehen, und schon sieht es aus wie ein Flachdach mit Brüstung.
Worauf du achten musst Die Funktion hat sechs Parameter. Das ist die Grenze, ab der cargo fmt die Parameterliste auf mehrere Zeilen verteilt, deshalb sieht der Kopf der Funktion anders aus als bei den anderen. Für Rust ist beides dasselbe. Wenn du deinen Code gelegentlich mit cargo fmt aufräumst, sieht er genauso aus wie hier. Mehr dazu in den Notizen.

Zum Schluss Beton und Glas für die Palette:

src/main.rsin struct Farben und in fn tag()
    schrift: Color,    beton: Color,
    fenster: Color,}    ⋮            schrift: Color::from_rgb8(0x1c, 0x2a, 0x3a),            beton: Color::from_rgb8(0xe8, 0xe2, 0xd6),
            fenster: Color::from_rgb8(0x5b, 0x8d, 0xb8),        }
Probelauf Speichern, dann im Terminal:
cargo run
Klick auf ein paar Parzellen. Überall, wo du klickst, steht jetzt ein kleines Haus mit blauen Fensterreihen. Bau mal zwei Häuser direkt hintereinander: Das vordere verdeckt das hintere richtig, und auch der Feldname hinter einem Haus verschwindet dahinter. Das ist der Maler-Algorithmus zusammen mit draw_with bei der Arbeit.
Schritt 9

Tag und Nacht

Zum Abschluss der Etappe gönnen wir uns etwas Stimmung. Ein Knopf oben rechts schaltet zwischen Tag und Nacht um. Nachts wird der Himmel dunkelblau mit Sternen, die Farben werden gedämpft, und in den Häusern gehen die Lichter an, aber nicht überall, sondern bunt gemischt wie in einer echten Stadt.

Das Spiel muss sich merken, ob Nacht ist, und es gibt einen Brief zum Umschalten. Das ! dreht einen Wahrheitswert um: aus true wird false und umgekehrt.

src/main.rsstruct Bauloewe, enum Message und fn update
    rohbauten: Vec<Feld>,    nacht: bool,}    ⋮    Geklickt(Feld),    TagNacht,}    ⋮                    self.rohbauten.push(feld);
                }
            }            Message::TagNacht => self.nacht = !self.nacht,        }

Welche Farbpalette gilt, entscheidet ab jetzt eine kleine Methode. Sie kommt unter view:

src/main.rsin impl Bauloewe, unter fn view
        stack![brett, kopfzeile].into()
    }
    fn farben(&self) -> Farben {
        if self.nacht {
            Farben::nacht()
        } else {
            Farben::tag()
        }
    }}

An den beiden Stellen, an denen wir bisher fest Farben::tag() benutzt haben, fragen wir jetzt diese Methode:

src/main.rsin fn view, erste Zeile
    fn view(&self) -> Element<'_, Message> {        let farben = Farben::tag();        let farben = self.farben();
src/main.rsin fn draw
        let mut frame = Frame::new(renderer, bounds.size());        let farben = Farben::tag();        let farben = self.farben();        let iso = Iso::neu(bounds.size());

Jetzt der Knopf. Er kommt in view zwischen Statustext und Kopfzeile, und die Kopfzeile bekommt ihn als viertes Element:

src/main.rsin fn view
        let status = text(hinweis).size(15).color(farben.schrift);
        let beschriftung = if self.nacht { "Tagmodus" } else { "Nachtmodus" };
        let schalter = button(text(beschriftung).size(14))
            .padding([6, 16])
            .on_press(Message::TagNacht)
            .style(move |_theme: &Theme, _status| button::Style {
                background: Some(Background::Color(farben.akzent)),
                text_color: Color::WHITE,
                border: Border {
                    radius: 16.0.into(),
                    ..Border::default()
                },
                ..button::Style::default()
            });
        let kopfzeile = row![titel, space::horizontal(), status]        let kopfzeile = row![titel, space::horizontal(), status, schalter]            .spacing(20)

Mit .style(…) geben wir dem Knopf ein eigenes Aussehen: orange, weiße Schrift, stark gerundete Ecken, also eine Pille. Das move vor der Closure bedeutet, dass die Closure sich die farben mitnimmt. Das klappt so einfach, weil Farben mit Copy markiert ist und einfach kopiert werden darf. Für Knopf und Rahmen brauchen wir noch drei Dinge aus iced:

src/main.rsganz oben, die use-Zeilen 3 und 4
use iced::widget::{canvas, row, space, stack, text};
use iced::{Color, Element, Fill, Font, Point, Rectangle, Renderer, Size, Theme, alignment, font};use iced::widget::{button, canvas, row, space, stack, text};
use iced::{
    Background, Border, Color, Element, Fill, Font, Point, Rectangle, Renderer, Size, Theme,
    alignment, font,
};

Jetzt der Himmel. Tagsüber scheint wie bisher die Sonne, nachts gibt es Sterne. Die Sonnen-Zeilen wandern dafür in den else-Zweig:

src/main.rsin fn zeichne_himmel, alles unter frame.fill_rectangle(…)
        frame.fill_rectangle(Point::ORIGIN, groesse, verlauf);
        // Die Sonne: ein weicher Schein aus vielen blassen Kreisen
        let sonne = Point::new(groesse.width * 0.84, groesse.height * 0.2);
        for ring in 0..12 {
            let radius = 30.0 + ring as f32 * 14.0;
            frame.fill(
                &Path::circle(sonne, radius),
                Color::from_rgba(1.0, 0.97, 0.85, 0.05),
            );
        }
        frame.fill(
            &Path::circle(sonne, 26.0),
            Color::from_rgb8(0xff, 0xf6, 0xd8),
        );        if self.nacht {
            // 90 Sterne, jede Nacht an denselben Stellen
            for nr in 0..90 {
                let x = (zufall(nr, 1) % 1000) as f32 / 1000.0;
                let y = (zufall(nr, 2) % 1000) as f32 / 1000.0;
                let punkt = Point::new(x * groesse.width, y * groesse.height * 0.55);
                let helligkeit = 0.3 + (zufall(nr, 3) % 70) as f32 / 100.0;
                frame.fill(
                    &Path::circle(punkt, 1.2),
                    Color {
                        a: helligkeit,
                        ..Color::WHITE
                    },
                );
            }
        } else {
            // Die Sonne: ein weicher Schein aus vielen blassen Kreisen
            let sonne = Point::new(groesse.width * 0.84, groesse.height * 0.2);
            for ring in 0..12 {
                let radius = 30.0 + ring as f32 * 14.0;
                frame.fill(
                    &Path::circle(sonne, radius),
                    Color::from_rgba(1.0, 0.97, 0.85, 0.05),
                );
            }
            frame.fill(
                &Path::circle(sonne, 26.0),
                Color::from_rgb8(0xff, 0xf6, 0xd8),
            );
        }    }

Für die Sterne brauchen wir Zufall: 90 Sterne an zufälligen Stellen mit zufälliger Helligkeit. Aber Vorsicht! Würden wir echte Zufallszahlen nehmen, sähe jedes Bild anders aus. Und draw wird ständig aufgerufen, bei jeder Mausbewegung. Die Sterne würden wild flackern und herumspringen. Wir brauchen also einen Zufall, der bei gleicher Frage immer gleich antwortet: „Wo liegt Stern Nummer 17?“ soll jedes Mal dieselbe Stelle ergeben.

Das leistet eine sogenannte Hash-Funktion. Sie verrührt ihre Eingabe so gründlich, dass das Ergebnis wie Zufall aussieht, aber bei gleicher Eingabe immer gleich ist. Sie kommt vor die Farben:

src/main.rsvor // Alle Farben an einem Ort
// Würfeln ohne Würfel: gleiche Eingabe, immer das gleiche Ergebnis
fn zufall(a: usize, b: usize) -> u32 {
    let mut h = (a as u32).wrapping_mul(2_654_435_761) ^ (b as u32).wrapping_mul(40_503);
    h ^= h >> 15;
    h = h.wrapping_mul(2_246_822_519);
    h ^= h >> 13;
    h
}
// Alle Farben an einem Ort

Wie genau die Zahlen darin gewählt sind, musst du dir nicht merken. Wichtig ist das Prinzip: Multiplizieren mit großen, krummen Zahlen und Verschieben der Bits (>>) mit anschließendem Mischen (^, das sogenannte exklusive Oder). Winzige Unterschiede in der Eingabe ergeben dadurch völlig verschiedene Ergebnisse. wrapping_mul heißt: Wenn die Zahl zu groß wird, beginnt sie einfach wieder von vorn, statt dass Rust mit einem Überlauf-Fehler abbricht. Mit % 1000 (Rest beim Teilen durch 1000) machen wir daraus in zeichne_himmel eine Zahl von 0 bis 999 und daraus einen Anteil an der Fensterbreite.

Dieselbe Funktion entscheidet, welche Fenster nachts leuchten. Jedes Fenster bekommt eine eigene Nummer, und zusammen mit der Position des Hauses ergibt das seine persönliche Zufallszahl. Ist der Rest beim Teilen durch 3 nicht null, brennt Licht, bei ungefähr zwei von drei Fenstern also:

src/main.rsin fn zeichne_rohbau, in der innersten Schleife
                for (seite, ecken) in [(0, links), (1, rechts)] {                    let farbe = if seite == 0 {                    let fenster_nr = stock * 4 + nr * 2 + seite;
                    let brennt =
                        self.nacht && zufall(feld.spalte * 16 + feld.reihe, fenster_nr) % 3 != 0;
                    let farbe = if brennt {
                        farben.licht
                    } else if seite == 0 {                        farben.fenster

Und damit man das Licht auch draußen sieht, fällt nachts ein warmer Schein auf den Rasen rund um jedes Haus. Er kommt direkt vor den Schatten:

src/main.rsin fn zeichne_rohbau, unter let haus = Quader { … };
            farbe: farben.beton,
        };
        // Nachts fällt warmes Licht aus den Fenstern auf den Rasen
        if self.nacht {
            let schein = [
                iso.punkt(s + 0.14, r + 0.14, boden),
                iso.punkt(s + 0.86, r + 0.14, boden),
                iso.punkt(s + 0.86, r + 0.86, boden),
                iso.punkt(s + 0.14, r + 0.86, boden),
            ];
            frame.fill(
                &vieleck(&schein),
                Color {
                    a: 0.18,
                    ..farben.licht
                },
            );
        }
        // Der Schatten fällt nach rechts auf den Rasen

Color { a: 0.18, ..farben.licht } nimmt die Lichtfarbe und ändert nur die Deckkraft auf 0.18. Zum Schluss die Farben: licht kommt als letztes Feld dazu, und neben tag() entsteht eine zweite Palette nacht() mit denselben Feldern in dunklen, bläulichen Tönen:

src/main.rsin struct Farben, am Ende
    fenster: Color,    licht: Color,}
src/main.rsin impl Farben, am Ende von tag() und darunter
            fenster: Color::from_rgb8(0x5b, 0x8d, 0xb8),            licht: Color::from_rgb8(0xff, 0xd2, 0x7a),        }
    }
    fn nacht() -> Farben {
        Farben {
            himmel_oben: Color::from_rgb8(0x0b, 0x10, 0x26),
            himmel_unten: Color::from_rgb8(0x2a, 0x23, 0x50),
            strasse: Color::from_rgb8(0x2a, 0x30, 0x42),
            stein: Color::from_rgb8(0x5c, 0x60, 0x6e),
            rasen: Color::from_rgb8(0x2e, 0x4a, 0x36),
            rasen_hell: Color::from_rgb8(0x4d, 0x7a, 0x4f),
            beschriftung: Color::from_rgba8(0xdc, 0xe6, 0xd2, 0.45),
            akzent: Color::from_rgb8(0xe0, 0x7a, 0x2e),
            schrift: Color::from_rgb8(0xe8, 0xec, 0xf5),
            beton: Color::from_rgb8(0x6d, 0x6a, 0x78),
            fenster: Color::from_rgb8(0x27, 0x32, 0x4a),
            licht: Color::from_rgb8(0xff, 0xd2, 0x7a),
        }
    }}
Worauf du achten musst Beide Paletten müssen exakt dieselben Felder haben, sonst meldet Rust missing field. Das ist eine echte Hilfe: Wenn wir später eine neue Farbe einführen, kann man sie nicht versehentlich im Nachtmodus vergessen.
Probelauf Speichern, dann im Terminal:
cargo run
Klick oben rechts auf „Nachtmodus“. Der Himmel wird dunkel und voller Sterne, und in deinen Rohbauten gehen die Lichter an. Zurück auf „Tagmodus“, und die Sonne ist wieder da. Die Sterne und beleuchteten Fenster stehen jedes Mal an denselben Stellen, egal wie oft du umschaltest oder die Maus bewegst.
Das Spielbrett bei Nacht: dunkelblauer Himmel mit Sternen, gedämpfte Parzellen und Häuser mit warm leuchtenden Fenstern
Nachtschicht auf der Baustelle.
Zwischenstand

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 = "bauloewe"
version = "0.1.0"
edition = "2024"

[dependencies]
iced = { version = "0.14", features = ["canvas"] }

src/main.rs

use iced::mouse;
use iced::widget::canvas::{Frame, Path, Stroke, Text, gradient};
use iced::widget::{button, canvas, row, space, stack, text};
use iced::{
    Background, Border, Color, Element, Fill, Font, Point, Rectangle, Renderer, Size, Theme,
    alignment, font,
};

// Das Brett: 12 Spalten (1 bis 12) und 9 Reihen (A bis I)
const SPALTEN: usize = 12;
const REIHEN: usize = 9;

// So groß erscheint ein Feld auf dem Bildschirm
const HALBE_BREITE: f32 = 40.0;
const HALBE_HOEHE: f32 = 20.0;

// Höhen in Pixeln
const PLATTE: f32 = 4.0;
const ANGEHOBEN: f32 = 9.0;
const STOCKWERK: f32 = 13.0;

fn main() -> iced::Result {
    iced::application(Bauloewe::default, Bauloewe::update, Bauloewe::view)
        .title("Baulöwe")
        .window_size((980.0, 680.0))
        .antialiasing(true)
        .run()
}

#[derive(Default)]
struct Bauloewe {
    maus_feld: Option<Feld>,
    rohbauten: Vec<Feld>,
    nacht: bool,
}

#[derive(Debug, Clone, Copy, PartialEq)]
struct Feld {
    spalte: usize,
    reihe: usize,
}

impl Feld {
    // Der Name wie auf dem Spielplan: "1A" bis "12I"
    fn name(self) -> String {
        let buchstabe = (b'A' + self.reihe as u8) as char;
        format!("{}{}", self.spalte + 1, buchstabe)
    }
}

#[derive(Debug, Clone)]
enum Message {
    MausUeber(Option<Feld>),
    Geklickt(Feld),
    TagNacht,
}

impl Bauloewe {
    fn update(&mut self, message: Message) {
        match message {
            Message::MausUeber(feld) => self.maus_feld = feld,
            Message::Geklickt(feld) => {
                if !self.rohbauten.contains(&feld) {
                    self.rohbauten.push(feld);
                }
            }
            Message::TagNacht => self.nacht = !self.nacht,
        }
    }

    fn view(&self) -> Element<'_, Message> {
        let farben = self.farben();

        let titel = text("BAULÖWE")
            .size(26)
            .font(Font {
                weight: font::Weight::Bold,
                ..Font::DEFAULT
            })
            .color(farben.schrift);

        let hinweis = match self.maus_feld {
            Some(feld) => format!("Feld {}", feld.name()),
            None => String::from("Fahr mit der Maus über das Baugrundstück"),
        };
        let status = text(hinweis).size(15).color(farben.schrift);

        let beschriftung = if self.nacht { "Tagmodus" } else { "Nachtmodus" };
        let schalter = button(text(beschriftung).size(14))
            .padding([6, 16])
            .on_press(Message::TagNacht)
            .style(move |_theme: &Theme, _status| button::Style {
                background: Some(Background::Color(farben.akzent)),
                text_color: Color::WHITE,
                border: Border {
                    radius: 16.0.into(),
                    ..Border::default()
                },
                ..button::Style::default()
            });

        let kopfzeile = row![titel, space::horizontal(), status, schalter]
            .spacing(20)
            .padding([14, 24])
            .align_y(alignment::Vertical::Center);

        let brett = canvas(self).width(Fill).height(Fill);

        // Die Kopfzeile liegt wie eine Folie über dem Brett
        stack![brett, kopfzeile].into()
    }

    fn farben(&self) -> Farben {
        if self.nacht {
            Farben::nacht()
        } else {
            Farben::tag()
        }
    }
}

impl canvas::Program<Message> for Bauloewe {
    type State = ();

    fn update(
        &self,
        _state: &mut Self::State,
        event: &canvas::Event,
        bounds: Rectangle,
        cursor: mouse::Cursor,
    ) -> Option<canvas::Action<Message>> {
        let iso = Iso::neu(bounds.size());
        let feld = cursor
            .position_in(bounds)
            .and_then(|punkt| iso.feld_unter(punkt));

        match event {
            canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
                feld.map(|feld| canvas::Action::publish(Message::Geklickt(feld)))
            }
            canvas::Event::Mouse(_) if feld != self.maus_feld => {
                Some(canvas::Action::publish(Message::MausUeber(feld)))
            }
            _ => None,
        }
    }

    fn draw(
        &self,
        _state: &Self::State,
        renderer: &Renderer,
        _theme: &Theme,
        bounds: Rectangle,
        _cursor: mouse::Cursor,
    ) -> Vec<canvas::Geometry> {
        let mut frame = Frame::new(renderer, bounds.size());
        let farben = self.farben();
        let iso = Iso::neu(bounds.size());

        self.zeichne_himmel(&mut frame, &farben, bounds.size());
        zeichne_sockel(&mut frame, &iso, &farben);

        // Von hinten nach vorne zeichnen, damit Vorderes Hinteres verdeckt
        let mut felder = Vec::new();
        for reihe in 0..REIHEN {
            for spalte in 0..SPALTEN {
                felder.push(Feld { spalte, reihe });
            }
        }
        felder.sort_by_key(|feld| feld.spalte + feld.reihe);

        for feld in felder {
            self.zeichne_parzelle(&mut frame, &iso, &farben, feld);
        }

        vec![frame.into_geometry()]
    }

    fn mouse_interaction(
        &self,
        _state: &Self::State,
        _bounds: Rectangle,
        _cursor: mouse::Cursor,
    ) -> mouse::Interaction {
        if self.maus_feld.is_some() {
            mouse::Interaction::Pointer
        } else {
            mouse::Interaction::default()
        }
    }
}

// Alles, was auf die Leinwand gezeichnet wird
impl Bauloewe {
    fn zeichne_himmel(&self, frame: &mut Frame, farben: &Farben, groesse: Size) {
        let verlauf = gradient::Linear::new(Point::ORIGIN, Point::new(0.0, groesse.height))
            .add_stop(0.0, farben.himmel_oben)
            .add_stop(1.0, farben.himmel_unten);
        frame.fill_rectangle(Point::ORIGIN, groesse, verlauf);

        if self.nacht {
            // 90 Sterne, jede Nacht an denselben Stellen
            for nr in 0..90 {
                let x = (zufall(nr, 1) % 1000) as f32 / 1000.0;
                let y = (zufall(nr, 2) % 1000) as f32 / 1000.0;
                let punkt = Point::new(x * groesse.width, y * groesse.height * 0.55);
                let helligkeit = 0.3 + (zufall(nr, 3) % 70) as f32 / 100.0;
                frame.fill(
                    &Path::circle(punkt, 1.2),
                    Color {
                        a: helligkeit,
                        ..Color::WHITE
                    },
                );
            }
        } else {
            // Die Sonne: ein weicher Schein aus vielen blassen Kreisen
            let sonne = Point::new(groesse.width * 0.84, groesse.height * 0.2);
            for ring in 0..12 {
                let radius = 30.0 + ring as f32 * 14.0;
                frame.fill(
                    &Path::circle(sonne, radius),
                    Color::from_rgba(1.0, 0.97, 0.85, 0.05),
                );
            }
            frame.fill(
                &Path::circle(sonne, 26.0),
                Color::from_rgb8(0xff, 0xf6, 0xd8),
            );
        }
    }

    fn zeichne_parzelle(&self, frame: &mut Frame, iso: &Iso, farben: &Farben, feld: Feld) {
        let s = feld.spalte as f32;
        let r = feld.reihe as f32;
        let unter_maus = self.maus_feld == Some(feld);
        let hoehe = if unter_maus {
            PLATTE + ANGEHOBEN
        } else {
            PLATTE
        };

        // Der Bordstein ist ein flacher Quader
        let rand = 0.1;
        let bordstein = Quader {
            s0: s + rand,
            r0: r + rand,
            s1: s + 1.0 - rand,
            r1: r + 1.0 - rand,
            z0: 0.0,
            z1: hoehe,
            farbe: farben.stein,
        };
        zeichne_quader(frame, iso, &bordstein);

        // Der Rasen liegt als Fläche oben auf dem Bordstein
        let innen = rand + 0.04;
        let rasen = [
            iso.punkt(s + innen, r + innen, hoehe),
            iso.punkt(s + 1.0 - innen, r + innen, hoehe),
            iso.punkt(s + 1.0 - innen, r + 1.0 - innen, hoehe),
            iso.punkt(s + innen, r + 1.0 - innen, hoehe),
        ];
        let gruen = if unter_maus {
            farben.rasen_hell
        } else {
            farben.rasen
        };
        frame.fill(&vieleck(&rasen), gruen);

        if unter_maus {
            let umriss = vieleck(&bordstein.oberseite(iso));
            frame.stroke(
                &umriss,
                Stroke::default().with_color(farben.akzent).with_width(2.0),
            );
        }

        if self.rohbauten.contains(&feld) {
            self.zeichne_rohbau(frame, iso, farben, feld, hoehe);
        } else {
            let schild = Text {
                content: feld.name(),
                position: iso.punkt(s + 0.5, r + 0.5, hoehe),
                color: farben.beschriftung,
                size: 11.0.into(),
                font: Font::MONOSPACE,
                align_x: text::Alignment::Center,
                align_y: alignment::Vertical::Center,
                ..Text::default()
            };
            // Als Formen zeichnen, damit Häuser davor die Schrift verdecken
            schild.draw_with(|form, farbe| frame.fill(&form, farbe));
        }
    }

    fn zeichne_rohbau(
        &self,
        frame: &mut Frame,
        iso: &Iso,
        farben: &Farben,
        feld: Feld,
        boden: f32,
    ) {
        let s = feld.spalte as f32;
        let r = feld.reihe as f32;
        let stockwerke = 3;
        let haus = Quader {
            s0: s + 0.2,
            r0: r + 0.2,
            s1: s + 0.8,
            r1: r + 0.8,
            z0: boden,
            z1: boden + stockwerke as f32 * STOCKWERK,
            farbe: farben.beton,
        };

        // Nachts fällt warmes Licht aus den Fenstern auf den Rasen
        if self.nacht {
            let schein = [
                iso.punkt(s + 0.14, r + 0.14, boden),
                iso.punkt(s + 0.86, r + 0.14, boden),
                iso.punkt(s + 0.86, r + 0.86, boden),
                iso.punkt(s + 0.14, r + 0.86, boden),
            ];
            frame.fill(
                &vieleck(&schein),
                Color {
                    a: 0.18,
                    ..farben.licht
                },
            );
        }

        // Der Schatten fällt nach rechts auf den Rasen
        let schatten = [
            iso.punkt(haus.s1, haus.r0, boden),
            iso.punkt(haus.s1 + 0.12, haus.r0 + 0.08, boden),
            iso.punkt(haus.s1 + 0.12, haus.r1, boden),
            iso.punkt(haus.s1, haus.r1, boden),
        ];
        frame.fill(&vieleck(&schatten), Color::from_rgba(0.0, 0.0, 0.0, 0.25));

        zeichne_quader(frame, iso, &haus);

        // Fenster: pro Stockwerk zwei auf jeder sichtbaren Seite
        for stock in 0..stockwerke {
            let unten = boden + stock as f32 * STOCKWERK + 4.0;
            let oben = unten + 6.0;
            for nr in 0..2 {
                let a = 0.08 + nr as f32 * 0.27;
                let b = a + 0.17;
                let links = [
                    iso.punkt(haus.s0 + a, haus.r1, oben),
                    iso.punkt(haus.s0 + b, haus.r1, oben),
                    iso.punkt(haus.s0 + b, haus.r1, unten),
                    iso.punkt(haus.s0 + a, haus.r1, unten),
                ];
                let rechts = [
                    iso.punkt(haus.s1, haus.r0 + a, oben),
                    iso.punkt(haus.s1, haus.r0 + b, oben),
                    iso.punkt(haus.s1, haus.r0 + b, unten),
                    iso.punkt(haus.s1, haus.r0 + a, unten),
                ];
                for (seite, ecken) in [(0, links), (1, rechts)] {
                    let fenster_nr = stock * 4 + nr * 2 + seite;
                    let brennt =
                        self.nacht && zufall(feld.spalte * 16 + feld.reihe, fenster_nr) % 3 != 0;
                    let farbe = if brennt {
                        farben.licht
                    } else if seite == 0 {
                        farben.fenster
                    } else {
                        abdunkeln(farben.fenster, 0.8)
                    };
                    frame.fill(&vieleck(&ecken), farbe);
                }
            }
        }

        // Das Flachdach bekommt einen Rand
        let innen = 0.05;
        let dach = [
            iso.punkt(haus.s0 + innen, haus.r0 + innen, haus.z1),
            iso.punkt(haus.s1 - innen, haus.r0 + innen, haus.z1),
            iso.punkt(haus.s1 - innen, haus.r1 - innen, haus.z1),
            iso.punkt(haus.s0 + innen, haus.r1 - innen, haus.z1),
        ];
        frame.fill(&vieleck(&dach), abdunkeln(farben.beton, 0.85));
    }
}

fn zeichne_sockel(frame: &mut Frame, iso: &Iso, farben: &Farben) {
    let rand = 0.35;
    let (s0, r0) = (-rand, -rand);
    let (s1, r1) = (SPALTEN as f32 + rand, REIHEN as f32 + rand);

    // Ein weicher Schatten unter dem ganzen Brett
    let schatten = [
        iso.punkt(s0, r0, -34.0),
        iso.punkt(s1, r0, -34.0),
        iso.punkt(s1, r1, -34.0),
        iso.punkt(s0, r1, -34.0),
    ];
    frame.fill(&vieleck(&schatten), Color::from_rgba(0.0, 0.0, 0.0, 0.18));

    let sockel = Quader {
        s0,
        r0,
        s1,
        r1,
        z0: -22.0,
        z1: 0.0,
        farbe: farben.strasse,
    };
    zeichne_quader(frame, iso, &sockel);
}

// Rechnet zwischen Brett und Bildschirm hin und her
struct Iso {
    ursprung: Point,
}

impl Iso {
    fn neu(groesse: Size) -> Iso {
        // Die Mitte des Bretts soll in der Mitte der Leinwand liegen
        let mitte_s = SPALTEN as f32 / 2.0;
        let mitte_r = REIHEN as f32 / 2.0;
        let x = groesse.width / 2.0 - (mitte_s - mitte_r) * HALBE_BREITE;
        let y = groesse.height / 2.0 - (mitte_s + mitte_r) * HALBE_HOEHE + 10.0;
        Iso {
            ursprung: Point::new(x, y),
        }
    }

    // Hinweg: Brett (Spalte, Reihe, Höhe) -> Bildschirm
    fn punkt(&self, s: f32, r: f32, z: f32) -> Point {
        Point::new(
            self.ursprung.x + (s - r) * HALBE_BREITE,
            self.ursprung.y + (s + r) * HALBE_HOEHE - z,
        )
    }

    // Rückweg: Bildschirm -> Feld auf dem Brett
    fn feld_unter(&self, punkt: Point) -> Option<Feld> {
        let dx = (punkt.x - self.ursprung.x) / HALBE_BREITE;
        let dy = (punkt.y - self.ursprung.y) / HALBE_HOEHE;
        let s = (dy + dx) / 2.0;
        let r = (dy - dx) / 2.0;
        if s < 0.0 || r < 0.0 {
            return None;
        }
        let spalte = s as usize;
        let reihe = r as usize;
        if spalte < SPALTEN && reihe < REIHEN {
            Some(Feld { spalte, reihe })
        } else {
            None
        }
    }
}

// Ein Quader: von (s0, r0) bis (s1, r1) auf dem Brett, von z0 bis z1 in der Höhe
struct Quader {
    s0: f32,
    r0: f32,
    s1: f32,
    r1: f32,
    z0: f32,
    z1: f32,
    farbe: Color,
}

impl Quader {
    fn oberseite(&self, iso: &Iso) -> [Point; 4] {
        [
            iso.punkt(self.s0, self.r0, self.z1),
            iso.punkt(self.s1, self.r0, self.z1),
            iso.punkt(self.s1, self.r1, self.z1),
            iso.punkt(self.s0, self.r1, self.z1),
        ]
    }
}

fn zeichne_quader(frame: &mut Frame, iso: &Iso, q: &Quader) {
    let links = [
        iso.punkt(q.s0, q.r1, q.z1),
        iso.punkt(q.s1, q.r1, q.z1),
        iso.punkt(q.s1, q.r1, q.z0),
        iso.punkt(q.s0, q.r1, q.z0),
    ];
    let rechts = [
        iso.punkt(q.s1, q.r0, q.z1),
        iso.punkt(q.s1, q.r1, q.z1),
        iso.punkt(q.s1, q.r1, q.z0),
        iso.punkt(q.s1, q.r0, q.z0),
    ];
    let oben = q.oberseite(iso);

    // Das Licht kommt von links: links mittel, rechts dunkel, oben am hellsten
    frame.fill(&vieleck(&links), abdunkeln(q.farbe, 0.78));
    frame.fill(&vieleck(&rechts), abdunkeln(q.farbe, 0.58));
    frame.fill(&vieleck(&oben), q.farbe);

    // Eine feine Lichtkante an den beiden vorderen Oberkanten
    let kante = Path::new(|p| {
        p.move_to(oben[3]);
        p.line_to(oben[2]);
        p.line_to(oben[1]);
    });
    frame.stroke(
        &kante,
        Stroke::default()
            .with_color(aufhellen(q.farbe, 0.35))
            .with_width(1.0),
    );
}

// Baut aus einer Liste von Ecken eine geschlossene Fläche
fn vieleck(ecken: &[Point]) -> Path {
    Path::new(|p| {
        p.move_to(ecken[0]);
        for ecke in &ecken[1..] {
            p.line_to(*ecke);
        }
        p.close();
    })
}

fn abdunkeln(farbe: Color, faktor: f32) -> Color {
    Color {
        r: farbe.r * faktor,
        g: farbe.g * faktor,
        b: farbe.b * faktor,
        a: farbe.a,
    }
}

fn aufhellen(farbe: Color, anteil: f32) -> Color {
    Color {
        r: farbe.r + (1.0 - farbe.r) * anteil,
        g: farbe.g + (1.0 - farbe.g) * anteil,
        b: farbe.b + (1.0 - farbe.b) * anteil,
        a: farbe.a,
    }
}

// Würfeln ohne Würfel: gleiche Eingabe, immer das gleiche Ergebnis
fn zufall(a: usize, b: usize) -> u32 {
    let mut h = (a as u32).wrapping_mul(2_654_435_761) ^ (b as u32).wrapping_mul(40_503);
    h ^= h >> 15;
    h = h.wrapping_mul(2_246_822_519);
    h ^= h >> 13;
    h
}

// Alle Farben an einem Ort
#[derive(Clone, Copy)]
struct Farben {
    himmel_oben: Color,
    himmel_unten: Color,
    strasse: Color,
    stein: Color,
    rasen: Color,
    rasen_hell: Color,
    beschriftung: Color,
    akzent: Color,
    schrift: Color,
    beton: Color,
    fenster: Color,
    licht: Color,
}

impl Farben {
    fn tag() -> Farben {
        Farben {
            himmel_oben: Color::from_rgb8(0x8e, 0xc5, 0xe8),
            himmel_unten: Color::from_rgb8(0xe9, 0xf2, 0xf7),
            strasse: Color::from_rgb8(0x5a, 0x62, 0x70),
            stein: Color::from_rgb8(0xd9, 0xd4, 0xc7),
            rasen: Color::from_rgb8(0x9c, 0xcb, 0x6b),
            rasen_hell: Color::from_rgb8(0xc4, 0xe6, 0x8f),
            beschriftung: Color::from_rgba8(0x1e, 0x32, 0x14, 0.55),
            akzent: Color::from_rgb8(0xce, 0x5a, 0x1e),
            schrift: Color::from_rgb8(0x1c, 0x2a, 0x3a),
            beton: Color::from_rgb8(0xe8, 0xe2, 0xd6),
            fenster: Color::from_rgb8(0x5b, 0x8d, 0xb8),
            licht: Color::from_rgb8(0xff, 0xd2, 0x7a),
        }
    }

    fn nacht() -> Farben {
        Farben {
            himmel_oben: Color::from_rgb8(0x0b, 0x10, 0x26),
            himmel_unten: Color::from_rgb8(0x2a, 0x23, 0x50),
            strasse: Color::from_rgb8(0x2a, 0x30, 0x42),
            stein: Color::from_rgb8(0x5c, 0x60, 0x6e),
            rasen: Color::from_rgb8(0x2e, 0x4a, 0x36),
            rasen_hell: Color::from_rgb8(0x4d, 0x7a, 0x4f),
            beschriftung: Color::from_rgba8(0xdc, 0xe6, 0xd2, 0.45),
            akzent: Color::from_rgb8(0xe0, 0x7a, 0x2e),
            schrift: Color::from_rgb8(0xe8, 0xec, 0xf5),
            beton: Color::from_rgb8(0x6d, 0x6a, 0x78),
            fenster: Color::from_rgb8(0x27, 0x32, 0x4a),
            licht: Color::from_rgb8(0xff, 0xd2, 0x7a),
        }
    }
}
ZusammenfassungAuf einer Leinwand (canvas) zeichnen wir mit einem Frame Flächen, Linien und Kreise selbst. Isometrie ist nur eine kleine Formel: x hängt von s − r ab, y von s + r und der Höhe. Aus Quadern mit drei unterschiedlich hellen Flächen entsteht räumliche Tiefe, und der Maler-Algorithmus sorgt dafür, dass Vorderes Hinteres verdeckt. Für die Maus rechnen wir die Formel rückwärts. Und wer beim Zeichnen Zufall braucht, nimmt eine Hash-Funktion, damit nichts flackert.
Ü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. Hochhaus: Lass die Rohbauten sechs statt drei Stockwerke hoch werden. Wie viele Zeilen musst du ändern? Und was passiert mit den Fenstern?
  2. Blick von oben: Setz HALBE_HOEHE auf 40, also genauso groß wie HALBE_BREITE. Wie sieht das Brett jetzt aus, und warum? Probier danach 12.
  3. Abrissbirne: Ein Klick mit der rechten Maustaste soll einen Rohbau wieder abreißen. Du brauchst dafür einen neuen Brief, einen neuen Zweig in update der Leinwand und die Methode retain, die aus einer Liste nur das behält, was eine Bedingung erfüllt.
Lösungen anzeigen

1. Genau eine Zeile: let stockwerke = 3; in zeichne_rohbau wird zu let stockwerke = 6;. Die Häuser werden doppelt so hoch, und die Fenster passen sich von selbst an, weil die Schleife for stock in 0..stockwerke einfach öfter läuft. Genau das meinen wir mit „programmierter Grafik“: Wir beschreiben ein Haus als Regel, nicht als Bild.

2. Bei 40 werden die Rauten zu auf der Spitze stehenden Quadraten. Das ist der Blick senkrecht von oben, nur um 45 Grad gedreht. Das Brett ist jetzt viel höher und passt nicht mehr ins Fenster. Die Häuser wirken gestaucht, weil ihre Höhe in Pixeln gleich geblieben ist, der Boden aber mehr Platz bekommt. Bei 12 wird der Blick sehr flach, wie aus einem niedrig fliegenden Flugzeug. Das Verhältnis 2 zu 1 ist ein Kompromiss, den fast alle Iso-Spiele nutzen, weil die Linien darin besonders sauber aussehen.

3. Drei Stellen ändern sich:

src/main.rsenum Message
    TagNacht,    Abreissen(Feld),}
src/main.rsin impl Bauloewe, fn update
            Message::TagNacht => self.nacht = !self.nacht,            Message::Abreissen(feld) => self.rohbauten.retain(|haus| *haus != feld),        }
src/main.rsin impl canvas::Program, fn update, vor dem Zweig für die Mausbewegung
            canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) => {
                feld.map(|feld| canvas::Action::publish(Message::Abreissen(feld)))
            }            canvas::Event::Mouse(_) if feld != self.maus_feld => {

retain geht die Liste durch und fragt für jedes Haus: „Darf es bleiben?“ Die Closure bekommt jedes Element als Verweis (&Feld), deshalb das Sternchen in *haus, um an den Wert selbst zu kommen. Alle Häuser, die nicht auf dem angeklickten Feld stehen, bleiben, das eine verschwindet.

Etappe 1 geschafft 🏗️

Du hast eine Leinwand bespielt, Isometrie in beide Richtungen gerechnet, aus Quadern ein ganzes Baugrundstück gebaut und eine kleine Stadt bei Tag und Nacht entstehen lassen. In der nächsten Etappe kommen die Spielregeln dazu: ein verdeckter Stapel mit 108 Bauplättchen, eine Hand mit sechs Plättchen, und gebaut wird nur noch dort, wo du ein Plättchen hast.

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

☕ Kaffee spendieren