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

Plättchen ziehen

Bisher durftest du überall bauen. Damit ist jetzt Schluss, denn ab heute gelten Spielregeln: 108 Bauplättchen liegen gemischt im Stapel, sechs davon hast du auf der Hand, und gebaut wird nur, wo du ein Plättchen hast. Dafür ziehen die Regeln in eine eigene Datei um, und auf dem Brett stecken kleine Vermessungsfähnchen.

4 Schritte · ca. 40 Minuten · iced 0.14 · rand 0.9

Das Spielbrett mit Vermessungsfähnchen auf sechs Parzellen und einer Leiste mit Plättchen am unteren Rand
So sieht der Baulöwe am Ende dieser Etappe aus.
Schritt 1

Die Regeln ziehen um

Bis jetzt steht alles in main.rs. Ab dieser Etappe bekommt das Spiel echte Regeln, und die wollen wir von Anfang an sauber von der Grafik trennen. Der Grund liegt einige Etappen voraus: Unsere Computergegner sollen später tausende Partien im Kopf durchspielen. Dafür brauchen sie die Regeln ohne Fenster, ohne Maus, ohne Farben. Deshalb bekommen die Regeln ein eigenes Modul, eine eigene Datei src/spiel.rs.

Und weil wir gleich Plättchen mischen wollen, holen wir uns die Zufalls-Bibliothek rand dazu:

cargo add rand@0.9

Leg jetzt neben main.rs eine neue Datei spiel.rs an. Hinein kommt zuerst nur, was ganz klar zu den Regeln gehört: die Größe des Bretts und das Feld.

src/spiel.rsneue Datei neben main.rs
// Das Brett: 12 Spalten (1 bis 12) und 9 Reihen (A bis I)
pub const SPALTEN: usize = 12;
pub const REIHEN: usize = 9;

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

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

Sieht aus wie vorher, mit einem Unterschied: überall steht pub davor. Alles in einem Modul ist nämlich erst einmal privat, also nur innerhalb der eigenen Datei sichtbar. pub (kurz für public, öffentlich) öffnet die Tür für andere Dateien. Bei einer Struktur muss man das sogar für jedes einzelne Feld sagen.

In main.rs löschst du die alten Konstanten und das alte Feld. Stattdessen melden wir das Modul ganz oben an und holen uns die drei Dinge heraus:

src/main.rsganz oben
mod spiel;
use iced::mouse;⋮    alignment, font,
};use spiel::{Feld, REIHEN, SPALTEN};
// 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
src/main.rsunter struct Bauloewe: das alte Feld komplett löschen
    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 {

mod spiel; sagt Rust: „Es gibt eine Datei spiel.rs, nimm sie mit ins Programm.“ Mit use spiel::{…} holen wir uns wie bei iced die Dinge heraus, die wir mit kurzem Namen benutzen wollen.

Probelauf Speichern, dann im Terminal:
cargo run
Das Programm sieht genauso aus wie am Ende von Etappe 1 und kann genauso viel. Das ist beim Umziehen genau richtig: Wir haben nur Möbel verschoben, nichts kaputt gemacht.
Schritt 2

Stapel und Hand

Im echten Spiel gibt es für jedes der 108 Felder genau ein Plättchen. Alle liegen verdeckt im Stapel, und jeder hat sechs davon auf der Hand. Bauen darf man nur auf einem Feld, dessen Plättchen man hat. Das bringen wir jetzt ins Spiel, und zwar in eine Struktur Spiel, die alles zusammenhält, was die Regeln wissen müssen. Zuerst ganz oben in spiel.rs das Mischwerkzeug und die Handgröße:

src/spiel.rsganz oben und unter den Konstanten
use rand::seq::SliceRandom;
// Das Brett: 12 Spalten (1 bis 12) und 9 Reihen (A bis I)
pub const SPALTEN: usize = 12;
pub const REIHEN: usize = 9;
// So viele Plättchen hat man auf der Hand
pub const HANDGROESSE: usize = 6;
#[derive(Debug, Clone, Copy, PartialEq)]

Und dann ans Ende der Datei das Spiel selbst:

src/spiel.rsganz am Ende der Datei
        format!("{}{}", self.spalte + 1, buchstabe)
    }
}
// Alles, was die Spielregeln wissen müssen
pub struct Spiel {
    pub gebaut: Vec<Feld>,
    pub stapel: Vec<Feld>,
    pub hand: Vec<Feld>,
}

impl Spiel {
    pub fn neu() -> Spiel {
        // Für jedes Feld ein Plättchen, dann gut mischen
        let mut stapel = Vec::new();
        for reihe in 0..REIHEN {
            for spalte in 0..SPALTEN {
                stapel.push(Feld { spalte, reihe });
            }
        }
        stapel.shuffle(&mut rand::rng());

        let mut spiel = Spiel {
            gebaut: Vec::new(),
            stapel,
            hand: Vec::new(),
        };
        spiel.nachziehen();
        spiel
    }

    // Füllt die Hand wieder auf, solange der Stapel reicht
    pub fn nachziehen(&mut self) {
        while self.hand.len() < HANDGROESSE {
            match self.stapel.pop() {
                Some(feld) => self.hand.push(feld),
                None => break,
            }
        }
        self.hand.sort_by_key(|feld| (feld.spalte, feld.reihe));
    }

    // Legt ein Plättchen aus der Hand. Gibt false zurück, wenn das nicht geht.
    pub fn legen(&mut self, feld: Feld) -> bool {
        let Some(stelle) = self.hand.iter().position(|&h| h == feld) else {
            return false;
        };
        self.hand.remove(stelle);
        self.gebaut.push(feld);
        self.nachziehen();
        true
    }
}

Drei Listen merkt sich das Spiel: was schon gebaut ist, was noch im stapel liegt und was du auf der hand hast. Die Methoden im Einzelnen:

  • neu legt für jedes Feld ein Plättchen in den Stapel und mischt ihn mit shuffle. rand::rng() ist der Zufallsgenerator, den rand uns fertig mitgibt. Das Mischen kommt von SliceRandom, das wir oben mit use geholt haben. Rust verlangt, dass man solche Zusatzfähigkeiten ausdrücklich einholt, sonst kennt die Liste shuffle gar nicht.
  • nachziehen nimmt so lange Plättchen vom Stapel, bis die Hand wieder sechs hat. pop nimmt das oberste Plättchen weg und gibt Some(feld) zurück, oder None, wenn der Stapel leer ist. Dann beendet break die Schleife. Zum Schluss sortieren wir die Hand, damit sie ordentlich aussieht.
  • legen sucht das Feld mit position in der Hand. Die Zeile mit let … else ist ein Türsteher: Nur wenn position etwas findet, geht es weiter, sonst verlässt die Funktion sofort mit false. Wer das Plättchen nicht hat, darf eben nicht bauen.
Worauf du achten musst |&h| h == feld: Das & vor dem h ist kein Tippfehler. iter() liefert Verweise auf die Felder, und mit &h packen wir den Verweis gleich aus, sodass h ein ganz normales Feld ist. Das geht, weil Feld Copy ist.

In main.rs tauschen wir die Liste der Rohbauten gegen ein ganzes Spiel. Weil Spiel::neu mischt, kann Rust es nicht mehr mit #[derive(Default)] selbst erzeugen. Wir schreiben den Anfangszustand deshalb von Hand in einer Funktion neu und geben die iced statt default:

src/main.rsganz oben und in fn main()
use spiel::{Feld, REIHEN, SPALTEN};use spiel::{Feld, REIHEN, SPALTEN, Spiel};⋮    iced::application(Bauloewe::default, Bauloewe::update, Bauloewe::view)    iced::application(Bauloewe::neu, Bauloewe::update, Bauloewe::view)
src/main.rsstruct Bauloewe
#[derive(Default)]struct Bauloewe {    spiel: Spiel,    maus_feld: Option<Feld>,    rohbauten: Vec<Feld>,    nacht: bool,
}
src/main.rsin impl Bauloewe, ganz oben und in fn update
impl Bauloewe {    fn neu() -> Bauloewe {
        Bauloewe {
            spiel: Spiel::neu(),
            maus_feld: None,
            nacht: false,
        }
    }
    fn update(&mut self, message: Message) {        ⋮            Message::Geklickt(feld) => {                if !self.rohbauten.contains(&feld) {
                    self.rohbauten.push(feld);
                }                self.spiel.legen(feld);            }
src/main.rsin fn zeichne_parzelle
        if self.rohbauten.contains(&feld) {        if self.spiel.gebaut.contains(&feld) {
Probelauf Speichern, dann im Terminal:
cargo run
Klick wild auf dem Brett herum. Meistens passiert nichts, aber ab und zu wächst ein Haus. Das sind die Felder, deren Plättchen zufällig auf deiner Hand liegen. Die Hand ist noch unsichtbar, aber die Regel gilt schon. Bei jedem Start ist es eine andere Hand, weil jedes Mal neu gemischt wird.
Schritt 3

Vermessungsfähnchen

Blind raten macht keinen Spaß. Auf jedes Feld, dessen Plättchen du auf der Hand hast, stellen wir deshalb ein kleines Vermessungsfähnchen, wie auf einem echten Bauplatz. Und nur diese Parzellen heben sich unter der Maus an. Das Fähnchen ist eine senkrechte Linie mit einem orangen Dreieck obendrauf. Es kommt als freier Helfer vor Iso:

src/main.rsvor // Rechnet zwischen Brett und Bildschirm …
// Ein Vermessungsfähnchen: Hier darfst du bauen
fn zeichne_faehnchen(frame: &mut Frame, iso: &Iso, farben: &Farben, feld: Feld, boden: f32) {
    let fuss = iso.punkt(feld.spalte as f32 + 0.3, feld.reihe as f32 + 0.3, boden);
    let spitze = Point::new(fuss.x, fuss.y - 20.0);
    let stange = Stroke::default().with_color(farben.stange).with_width(1.5);
    frame.stroke(&Path::line(fuss, spitze), stange);

    let fahne = [
        spitze,
        Point::new(spitze.x + 11.0, spitze.y + 3.5),
        Point::new(spitze.x, spitze.y + 7.0),
    ];
    frame.fill(&vieleck(&fahne), farben.akzent);
}
// Rechnet zwischen Brett und Bildschirm hin und her

Der Fuß steht ein Stück hinter der Feldmitte, damit er dem Namensschild nicht in die Quere kommt. Von dort geht die Stange 20 Pixel gerade nach oben. Das dürfen wir einfach direkt in Bildschirmpixeln rechnen, denn senkrecht bleibt in unserer Ansicht senkrecht. Path::line ist eine fertige Abkürzung für eine Linie zwischen zwei Punkten.

In zeichne_parzelle fragen wir, ob das Feld zu deinem Bauplan gehört. Nur dann darf es sich anheben, und nur dann bekommt es ein Fähnchen:

src/main.rsin fn zeichne_parzelle, oben
        let r = feld.reihe as f32;        let unter_maus = self.maus_feld == Some(feld);        let im_plan = self.spiel.hand.contains(&feld);
        let unter_maus = im_plan && self.maus_feld == Some(feld);
src/main.rsin fn zeichne_parzelle, vor if self.spiel.gebaut.contains(…)
        if im_plan {
            zeichne_faehnchen(frame, iso, farben, feld, hoehe);
        }
        if self.spiel.gebaut.contains(&feld) {

&& heißt „und“: Angehoben wird nur, wenn das Feld im Plan ist und unter der Maus liegt. Auch der Mauszeiger soll nur dort zur Hand werden, wo man wirklich bauen kann. Dafür nehmen wir wieder einen match-Zweig mit Bedingung:

src/main.rsin impl canvas::Program, fn mouse_interaction
    ) -> mouse::Interaction {        if self.maus_feld.is_some() {
            mouse::Interaction::Pointer
        } else {
            mouse::Interaction::default()        match self.maus_feld {
            Some(feld) if self.spiel.hand.contains(&feld) => mouse::Interaction::Pointer,
            _ => mouse::Interaction::default(),        }
    }

Fehlt noch die Farbe der Stange, in beiden Paletten:

src/main.rsin struct Farben und in beiden Paletten
    licht: Color,    stange: Color,}    ⋮            licht: Color::from_rgb8(0xff, 0xd2, 0x7a),            stange: Color::WHITE,        }
    }

    fn nacht() -> Farben {        ⋮            licht: Color::from_rgb8(0xff, 0xd2, 0x7a),            stange: Color::from_rgb8(0xcf, 0xd3, 0xdc),        }
Probelauf Speichern, dann im Terminal:
cargo run
Sechs Fähnchen stecken auf dem Brett. Fahr mit der Maus darüber: Nur diese Parzellen heben sich, und nur dort wird der Zeiger zur Hand. Klick auf eine davon, und das Haus steht. Gleichzeitig taucht irgendwo ein neues Fähnchen auf, denn du hast ein Plättchen nachgezogen.
Schritt 4

Die Hand im Fenster

Fehlt noch der Überblick: Welche Plättchen hast du eigentlich, und wie viele liegen noch im Stapel? Unten ins Fenster kommt deshalb eine Leiste mit sechs kleinen Plättchen zum Anklicken. Ein Klick darauf verschickt denselben Brief wie ein Klick aufs Brett, nämlich Message::Geklickt. Der Rest des Programms merkt gar nicht, woher der Brief kam.

Die Leiste bauen wir in view anstelle der letzten beiden Zeilen:

src/main.rsin fn view, die letzten beiden Zeilen ersetzen
        let brett = canvas(self).width(Fill).height(Fill);
        // Die Kopfzeile liegt wie eine Folie über dem Brett
        stack![brett, kopfzeile].into()        // Unten: die Plättchen auf deiner Hand
        let mut hand = row![text("Deine Plättchen:").size(15).color(farben.schrift)]
            .spacing(10)
            .align_y(alignment::Vertical::Center);
        for &feld in &self.spiel.hand {
            let plaettchen = button(text(feld.name()).size(16).font(Font::MONOSPACE))
                .padding([8, 14])
                .on_press(Message::Geklickt(feld))
                .style(move |_theme: &Theme, status| {
                    let grund = match status {
                        button::Status::Hovered => farben.rasen_hell,
                        _ => farben.stein,
                    };
                    button::Style {
                        background: Some(Background::Color(grund)),
                        text_color: farben.schrift,
                        border: Border {
                            radius: 6.0.into(),
                            ..Border::default()
                        },
                        ..button::Style::default()
                    }
                });
            hand = hand.push(plaettchen);
        }

        let stapel = text(format!("Im Stapel: {}", self.spiel.stapel.len()))
            .size(14)
            .color(farben.schrift);

        let fussleiste = row![hand, space::horizontal(), stapel]
            .padding([16, 24])
            .align_y(alignment::Vertical::Center);

        // Kopf- und Fußleiste liegen wie eine Folie über dem Brett
        let folie = column![kopfzeile, space::vertical(), fussleiste];
        stack![brett, folie].into()    }
  • Eine Reihe, die wächst. Wir beginnen mit einer row!, in der nur die Beschriftung steht, und hängen dann in einer Schleife für jedes Plättchen einen Knopf an. Weil push die Reihe verändert, muss sie let mut sein. for &feld in &self.spiel.hand geht die Hand durch, ohne sie zu verändern, und packt jeden Verweis gleich zu einem Feld aus.
  • Knöpfe wie Plättchen. Der Stil ähnelt dem Tag/Nacht-Knopf, bekommt aber den status ausgewertet: Schwebt die Maus über dem Knopf (Hovered), wird er hellgrün, sonst hat er die Farbe der Bordsteine. So passen die Plättchen farblich zum Brett, bei Tag und bei Nacht.
  • Die Folie. Statt nur der Kopfzeile legen wir jetzt eine column! übers Brett: oben die Kopfzeile, unten die Fußleiste und dazwischen space::vertical(), ein dehnbarer Leerraum, der die beiden auseinander an die Fensterränder schiebt.

Für die Spalte brauchen wir noch column aus iced:

src/main.rsganz oben, dritte use-Zeile
use iced::widget::canvas::{Frame, Path, Stroke, Text, gradient};use iced::widget::{button, canvas, row, space, stack, text};use iced::widget::{button, canvas, column, row, space, stack, text};
Probelauf Speichern, dann im Terminal:
cargo run
Unten links liegen jetzt deine sechs Plättchen, unten rechts steht, wie viele noch im Stapel sind. Klick auf ein Plättchen in der Leiste: Auf dem Brett wächst das Haus, das Plättchen verschwindet, ein neues kommt nach, und der Stapel wird um eins kleiner.
Das Spielbrett mit einigen Häusern und sechs orangen Vermessungsfähnchen. Unten eine Leiste mit sechs Plättchen-Knöpfen und dem Hinweis Im Stapel: 100.
So sieht der Baulöwe am Ende von Etappe 2 aus.
Zwischenstand

Der komplette Code nach Etappe 2

Hier ist der vollständige Stand nach Etappe 2. Nimm ihn zum Vergleichen, wenn irgendwo ein Fehler steckt, den du nicht findest, oder als Einstiegspunkt, wenn du erst bei der nächsten Etappe dazustößt.

Cargo.toml, src/main.rs, src/spiel.rs anzeigen

Cargo.toml

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

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

src/main.rs

mod spiel;

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

// 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::neu, Bauloewe::update, Bauloewe::view)
        .title("Baulöwe")
        .window_size((980.0, 680.0))
        .antialiasing(true)
        .run()
}

struct Bauloewe {
    spiel: Spiel,
    maus_feld: Option<Feld>,
    nacht: bool,
}

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

impl Bauloewe {
    fn neu() -> Bauloewe {
        Bauloewe {
            spiel: Spiel::neu(),
            maus_feld: None,
            nacht: false,
        }
    }

    fn update(&mut self, message: Message) {
        match message {
            Message::MausUeber(feld) => self.maus_feld = feld,
            Message::Geklickt(feld) => {
                self.spiel.legen(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);

        // Unten: die Plättchen auf deiner Hand
        let mut hand = row![text("Deine Plättchen:").size(15).color(farben.schrift)]
            .spacing(10)
            .align_y(alignment::Vertical::Center);
        for &feld in &self.spiel.hand {
            let plaettchen = button(text(feld.name()).size(16).font(Font::MONOSPACE))
                .padding([8, 14])
                .on_press(Message::Geklickt(feld))
                .style(move |_theme: &Theme, status| {
                    let grund = match status {
                        button::Status::Hovered => farben.rasen_hell,
                        _ => farben.stein,
                    };
                    button::Style {
                        background: Some(Background::Color(grund)),
                        text_color: farben.schrift,
                        border: Border {
                            radius: 6.0.into(),
                            ..Border::default()
                        },
                        ..button::Style::default()
                    }
                });
            hand = hand.push(plaettchen);
        }

        let stapel = text(format!("Im Stapel: {}", self.spiel.stapel.len()))
            .size(14)
            .color(farben.schrift);

        let fussleiste = row![hand, space::horizontal(), stapel]
            .padding([16, 24])
            .align_y(alignment::Vertical::Center);

        // Kopf- und Fußleiste liegen wie eine Folie über dem Brett
        let folie = column![kopfzeile, space::vertical(), fussleiste];
        stack![brett, folie].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 {
        match self.maus_feld {
            Some(feld) if self.spiel.hand.contains(&feld) => mouse::Interaction::Pointer,
            _ => 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 im_plan = self.spiel.hand.contains(&feld);
        let unter_maus = im_plan && 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 im_plan {
            zeichne_faehnchen(frame, iso, farben, feld, hoehe);
        }

        if self.spiel.gebaut.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);
}

// Ein Vermessungsfähnchen: Hier darfst du bauen
fn zeichne_faehnchen(frame: &mut Frame, iso: &Iso, farben: &Farben, feld: Feld, boden: f32) {
    let fuss = iso.punkt(feld.spalte as f32 + 0.3, feld.reihe as f32 + 0.3, boden);
    let spitze = Point::new(fuss.x, fuss.y - 20.0);
    let stange = Stroke::default().with_color(farben.stange).with_width(1.5);
    frame.stroke(&Path::line(fuss, spitze), stange);

    let fahne = [
        spitze,
        Point::new(spitze.x + 11.0, spitze.y + 3.5),
        Point::new(spitze.x, spitze.y + 7.0),
    ];
    frame.fill(&vieleck(&fahne), farben.akzent);
}

// 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,
    stange: 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),
            stange: Color::WHITE,
        }
    }

    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),
            stange: Color::from_rgb8(0xcf, 0xd3, 0xdc),
        }
    }
}

src/spiel.rs

use rand::seq::SliceRandom;

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

// So viele Plättchen hat man auf der Hand
pub const HANDGROESSE: usize = 6;

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

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

// Alles, was die Spielregeln wissen müssen
pub struct Spiel {
    pub gebaut: Vec<Feld>,
    pub stapel: Vec<Feld>,
    pub hand: Vec<Feld>,
}

impl Spiel {
    pub fn neu() -> Spiel {
        // Für jedes Feld ein Plättchen, dann gut mischen
        let mut stapel = Vec::new();
        for reihe in 0..REIHEN {
            for spalte in 0..SPALTEN {
                stapel.push(Feld { spalte, reihe });
            }
        }
        stapel.shuffle(&mut rand::rng());

        let mut spiel = Spiel {
            gebaut: Vec::new(),
            stapel,
            hand: Vec::new(),
        };
        spiel.nachziehen();
        spiel
    }

    // Füllt die Hand wieder auf, solange der Stapel reicht
    pub fn nachziehen(&mut self) {
        while self.hand.len() < HANDGROESSE {
            match self.stapel.pop() {
                Some(feld) => self.hand.push(feld),
                None => break,
            }
        }
        self.hand.sort_by_key(|feld| (feld.spalte, feld.reihe));
    }

    // Legt ein Plättchen aus der Hand. Gibt false zurück, wenn das nicht geht.
    pub fn legen(&mut self, feld: Feld) -> bool {
        let Some(stelle) = self.hand.iter().position(|&h| h == feld) else {
            return false;
        };
        self.hand.remove(stelle);
        self.gebaut.push(feld);
        self.nachziehen();
        true
    }
}
ZusammenfassungEin Modul ist eine eigene Datei mit eigenem Namensraum. Mit mod melden wir es an, mit pub öffnen wir, was andere Dateien sehen dürfen. Die Spielregeln wohnen jetzt in spiel.rs, getrennt von der Grafik. Mit rand mischen wir, mit pop ziehen wir, und let … else lässt eine Funktion früh aussteigen, wenn etwas nicht passt. Ein Klick in der Leiste und ein Klick aufs Brett schicken denselben Brief: Die Oberfläche fragt, die Regeln entscheiden.
Übungen

Zum Ausprobieren

  1. Große Hand: Wie bekommst du acht statt sechs Plättchen auf die Hand? Passen alle noch in die Fußleiste?
  2. Buchhaltung: Zeig in der Fußleiste neben dem Stapel auch an, wie viele Häuser schon stehen, etwa „Gebaut: 5 · Im Stapel: 95“.
  3. Der erste Test: Weil die Regeln nichts von Fenstern wissen, kann man sie ohne Fenster prüfen. Schreib einen Test, der sicherstellt, dass ein neues Spiel sechs Plättchen auf der Hand und 102 im Stapel hat, und starte ihn mit cargo test.
Lösungen anzeigen

1. Eine Zeile in spiel.rs: pub const HANDGROESSE: usize = 8;. Die Leiste wächst mit, weil die Schleife in view einfach alle Plättchen der Hand durchgeht. Bei 980 Pixeln Fensterbreite wird es aber eng.

2. In view die Zeile für den Stapeltext ändern:

let stapel = text(format!(
    "Gebaut: {} · Im Stapel: {}",
    self.spiel.gebaut.len(),
    self.spiel.stapel.len()
))

Jedes {} im Text wird der Reihe nach durch einen der Werte dahinter ersetzt.

3. Ans Ende von spiel.rs:

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn neues_spiel_verteilt_sechs_plaettchen() {
        let spiel = Spiel::neu();
        assert_eq!(spiel.hand.len(), 6);
        assert_eq!(spiel.stapel.len(), 102);
    }
}

#[cfg(test)] bedeutet: Dieser Teil wird nur beim Testen übersetzt. use super::* holt alles aus dem umgebenden Modul herein, und assert_eq! bricht den Test ab, wenn die beiden Werte nicht gleich sind. cargo test meldet dann test result: ok. 1 passed. Mehr über Tests steht im Buch, Kapitel 88.

Etappe 2 geschafft 🚩

Die Regeln haben ein eigenes Zuhause, der Stapel ist gemischt, und gebaut wird nur noch mit Plättchen. In der nächsten Etappe passiert das, worum sich im Spiel alles dreht: Liegen Häuser nebeneinander, gründest du eine Hotelkette, und aus Rohbauten werden Hoteltürme mit eigenem Baustil.

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

☕ Kaffee spendieren