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.
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:
Nach fast jedem Schritt machen wir einen Probelauf. Kleine Schritte, oft prüfen: So findest du Tippfehler sofort und nicht erst zwanzig Minuten später.
Das Projekt anlegen
Ö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"] }
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.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.
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 Bauloeweist 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 Messageist der Briefkasten für Ereignisse, etwa „Feld 7C wurde angeklickt“. Noch gibt es keine.updateliest die Briefe und ändert das Gedächtnis,viewbeschreibt, was auf dem Bildschirm zu sehen ist.- In
mainreichen wir iced diese drei Dinge und stellen das Fenster ein: Titel, Größe in Pixeln undantialiasing. 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.
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.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:
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:
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:
} }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 inBauloewe. Die leeren Klammern()sind Rusts Art, „nichts“ zu sagen.drawruft iced jedes Mal auf, wenn neu gezeichnet werden muss. Wir bekommen unter anderembounds, die aktuelle Größe der Leinwand. Die Parameter mit Unterstrich vorn brauchen wir nicht.- Ein
Frameist 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:
} }// 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::Linearläuft von einem Startpunkt zu einem Endpunkt, hier von ganz oben nach ganz unten. Mitadd_stoplegen 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_rgbaist 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.
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.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.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:
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:
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:
// 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::neurechnet 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.punktist unsere Formel von oben, einmal in Rust.vielecknimmt 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:
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:
himmel_unten: Color,rasen: Color,}⋮himmel_unten: Color::from_rgb8(0xe9, 0xf2, 0xf7),rasen: Color::from_rgb8(0x9c, 0xcb, 0x6b),}
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.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.
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:
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:
// 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
oberseiteliefert die vier Ecken des Deckels, alle auf der Höhez1. 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
r1und reicht von oben (z1) bis unten (z0), die rechte Seite genauso an der Kantes1. Schau dir die Ecken an: Beilinkssteht überallq.r1, beirechtsüberallq.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:
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:
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:
self.zeichne_himmel(&mut frame, &farben, bounds.size());zeichne_sockel(&mut frame, &iso, &farben);
Und die Palette bekommt ihr Asphaltgrau:
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),
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.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:
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:
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:
#[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 {}
derivelässt Rust Fähigkeiten automatisch dazubauen.Copybedeutet: EinFeldist so klein, dass Rust es beim Weitergeben einfach kopieren darf, ohne dass wir über Ausleihen nachdenken müssen.PartialEqerlaubt Vergleiche mit==. Beides brauchen wir im nächsten Schritt.namebaut 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“, undas charmacht 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:
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:
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
randkleiner 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
Textmitten auf dem Rasen...Text::default()heißt: Alle Einstellungen, die wir nicht selbst angeben, bekommen ihren Standardwert.
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:
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),}
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.
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:
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:
struct Bauloewe {}struct Bauloewe { maus_feld: Option<Feld>, }
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:
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, oderNone, wenn die Maus außerhalb ist.and_thenrechnet nur weiter, wenn es eine Position gibt, und gibt sie dann anfeld_unter.canvas::Event::Mouse(_) if feld != self.maus_feldist einmatch-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ätteupdateviel Arbeit für nichts.canvas::Action::publishwirft den Brief in den Briefkasten. Danach ruft iced unserupdateauf 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.
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.
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:
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.
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:
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:
const PLATTE: f32 = 4.0;const ANGEHOBEN: f32 = 9.0;
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),}
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.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:
const ANGEHOBEN: f32 = 9.0;const STOCKWERK: f32 = 13.0;
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:
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:
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:
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:
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.
aundbsind 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 nurs, auf der rechten nurr. - 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.
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:
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),}
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.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.
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:
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:
fn view(&self) -> Element<'_, Message> {let farben = Farben::tag();let farben = self.farben();
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:
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:
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:
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:
// 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:
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:
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:
fenster: Color,licht: Color,}
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), } }}
missing field. Das ist eine echte Hilfe: Wenn wir später eine neue Farbe
einführen, kann man sie nicht versehentlich im Nachtmodus vergessen.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.
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),
}
}
}
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.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.
- Hochhaus: Lass die Rohbauten sechs statt drei Stockwerke hoch werden. Wie viele Zeilen musst du ändern? Und was passiert mit den Fenstern?
- Blick von oben: Setz
HALBE_HOEHEauf 40, also genauso groß wieHALBE_BREITE. Wie sieht das Brett jetzt aus, und warum? Probier danach 12. - Abrissbirne: Ein Klick mit der rechten Maustaste soll einen
Rohbau wieder abreißen. Du brauchst dafür einen neuen Brief, einen neuen Zweig in
updateder Leinwand und die Methoderetain, 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:
TagNacht,Abreissen(Feld),}
Message::TagNacht => self.nacht = !self.nacht,Message::Abreissen(feld) => self.rohbauten.retain(|haus| *haus != feld),}
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