Blog

Zelluläre Automaten mit Rust: Teil III

Todd Smith

Todd Smith

Aktualisiert Oktober 15, 2025
14 Minuten

Im zweiten Beitrag dieser dreiteiligen Serie haben wir die Architektur des Entity-Component-Systems (ECS) erkundet und mit Bevy eine statische Benutzeroberfläche erstellt. In diesem Beitrag, dem Finale der dreiteiligen Serie, implementieren wir die zahlreichen Systeme, die zusammen die Entwicklung unseres elementaren zellulären Automaten vorantreiben und die Benutzerinteraktion über die Tastatur und die Maus unterstützen. In diesem Beitrag werden wir ausschließlich in src/ecs.rs arbeiten.

Der Evolver

Wir haben jetzt die Benutzeroberfläche, aber sie ist ein Tableau - ein Moment in der Zeit, der in seiner ursprünglichen Darstellung verschlossen ist. Schalten wir es frei, indem wir seine Daseinsberechtigung erfüllen: die Evolution des zellulären Automaten!
fn evolve(
    time: Res<Time>,
    rule: Res<AutomatonRule>,
    mut timer: ResMut<EvolutionTimer>,
    mut history: ResMut<History>,
    mut cells: Query<(&CellPosition, &mut BackgroundColor)>
) {
    if timer.is_running()
    {
        timer.tick(time.delta(), || {
            history.evolve(*rule);
            for (position, mut color) in &mut cells
            {
                *color = liveness_color(history[*position]);
            }
        });
    }
}
Wir haben bereits Res gesehen, daher ist es nicht schwer zu erraten, was ResMut ist: eine veränderbare Ausleihe einer Ressource, die unserem evolve System durch Dependency Injection zur Verfügung gestellt wird. Time ist eine von Bevy bereitgestellte Uhr-Ressource. Sie zeigt an, wie viel Zeit seit ihrer Erstellung oder der letzten Aktualisierung verstrichen ist, was über die Methode delta abgefragt werden kann. Bevy aktualisiert diesen Wert bei jedem Frame, bevor ein System im Update Zeitplan ausgeführt wird. Jetzt kommt der magische Teil! Query ist praktisch ein Iterator über alle Entitäten, die die angegebene Kombination von Komponenten besitzen. Bevy übergibt also jede Entität, die derzeit sowohl eine CellPosition als auch eine BackgroundColor hat. Dies sind zufällig die Zellen unserer visuellen Geschichte. Lassen Sie uns also die Logik in eine Erzählung umwandeln, bevor wir auf die einzelnen Teile eingehen:
  1. Prüfen Sie, ob die EvolutionTimer läuft. Dies ist eine der Ressourcen unseres Projekts. Wir haben gesehen, dass sie in unserem Plugin-Setup-Code registriert ist, aber wir haben das noch nicht untersucht. Das werden wir ein paar Absätze weiter unten in diesem Beitrag nachholen, aber für den Moment stellen wir fest, dass die Anwendung pausiert startet und zwischen pausiert und ausgeführt wechselt, wenn der Benutzer die Leertaste drückt.
  2. Nehmen Sie an, dass die EvolutionTimer läuft. Ticken Sie nun manuell nach der Zeit, die seit dem letzten Frame verstrichen ist, was dazu führen kann, dass der Timer abläuft.
  3. Nehmen Sie an, dass die EvolutionTimer abgelaufen ist. Entwickeln Sie nun den zellulären Automaten entsprechend der injizierten Regel weiter und aktualisieren Sie die Hintergrundfarbe jeder Zelle, damit sie mit dem neuen Modellzustand übereinstimmt. Unser Query Iterator antwortet hier nur auf 2-Tupel, also destrukturieren wir sie, um den Aktualisierungsprozess zu vereinfachen.

EvolutionTimer und manuelles Ticken

Sie werden wahrscheinlich nicht überrascht sein, wenn Sie feststellen, dass EvolutionTimer ist nur ein weiterer Newtype um eine bestehende Bevy-Struktur:
#[derive(Resource)]
struct EvolutionTimer(Timer);
Bevy-Timer unterstützen eine Dauer, einen Wiederholungsmodus (entweder Once oder Repeating) und einen Laufmodus (entweder laufend oder pausiert). Wie unser eigener Wrapper muss auch Timer über die Methode tick manuell "angeklickt" werden. Damit haben wir völlige Freiheit, wie wir einen bestimmten Timer mit der Engine-Schleife oder der Wanduhr verbinden. Wir führen eine new Methode ein, um einen EvolutionTimer für unsere besonderen Umstände zu konfigurieren.
const HEARTBEAT: Duration = Duration::from_millis(250);

impl EvolutionTimer
{
    fn new() -> Self
    {
        Self({
            let mut timer = Timer::new(HEARTBEAT, TimerMode::Repeating);
            timer.pause();
            timer
        })
    }
}
Wir verwenden hier einen Inline-Block, um ein Timer zu erstellen, es anzuhalten und es in ein EvolutionTimer einzubetten.
impl EvolutionTimer
{
    fn is_running(&self) -> bool
    {
        !self.0.paused()
    }

    #[inline]
    fn tick(&mut self, delta: Duration, on_expired: impl FnOnce())
    {
        self.0.tick(delta);
        if self.0.finished()
        {
            on_expired();
        }
    }
}
Jetzt sehen wir, dass is_running nur testet, ob der interne Bevy-Timer pausiert ist. Der Aufruf von tick delegiert lediglich an die gleichnamige Methode des internen Timers und macht die Ausführung der bereitgestellten Closure davon abhängig, dass der interne Timer die ihm zugewiesene Zeitdauer erreicht hat.

Das Umschalten der EvolutionTimer

Das Umschalten der EvolutionTimer ist ziemlich simpel, so dass es dazu eigentlich nichts Interessantes zu sagen gibt:
impl EvolutionTimer
{
    fn toggle(&mut self)
    {
        match self.0.paused()
        {
            true => self.0.unpause(),
            false => self.0.pause()
        }
    }
}
Viel interessanter ist es, dem Benutzer die Möglichkeit zu geben, die EvolutionTimer umzuschalten:
fn maybe_toggle_instructions(
    keys: Res<Input<KeyCode>>,
    mut instructions: Query<&mut Style, With<Instructions>>,
    mut timer: ResMut<EvolutionTimer>
) {
    if keys.just_pressed(KeyCode::Space)
    {
        timer.toggle();
        let style = &mut instructions.single_mut();
        style.display = match style.display
        {
            Display::Flex => Display::None,
            Display::None => Display::Flex,
            Display::Grid => unreachable!()
        };
    }
}
Es gibt zwei neue Dinge in der Signatur:
  1. Input ist eine Systemressource, die Zugriff auf "drückbare" Eingaben wie eine Tastatur, eine Maustaste oder ein Gamepad bietet. Der Typ-Parameter, KeyCode, entspricht einer "gekochten" Tastatureingabe. Ich sage "gekocht", weil es sich nicht um die rohen Tastencodes des Betriebssystems handelt, sondern um die plattformübergreifenden Abstraktionen von Tastenereignissen in Bevy. Wir können eine Input fragen, ob sie "gerade gedrückt" (seit dem letzten Frame), "gedrückt" (über mehrere Frames hinweg) oder "gerade losgelassen" (seit dem letzten Frame) ist.
  2. With handelt es sich um einen positiven Filter für eine Query: wir wollen jede Entität, die sowohl eine Style -Komponente als auch eine Instructions -Komponente hat, aber wir brauchen keinen Zugriff auf die Instructions -Komponente selbst. Erinnern Sie sich daran, dass Instructions eine leere Marker-Struktur war. Es hätte also keinen Sinn, sie lokal zu binden, da es nichts zu lesen und nichts zu schreiben gibt.
Der Code selbst ist einfach. Wenn der Benutzer die Leertaste drückt, schalten Sie die EvolutionTimer um, extrahieren die einzige Style aus der Singleton-Abfrage und schalten die Eigenschaft display zwischen Flex und None um. Woher wissen wir, dass es nur eine Style gibt? Konstruktion: Wir haben nur eine Entität mit dem Label gebrandmarkt. gerät in Panik, wenn Sie das falsch machen, also hat es den Nebeneffekt, dass es als Behauptung fungiert. Apropos Behauptung: Wir rufen unreachable! auf, wenn das alte display Grid ist, da dies von der Struktur unseres Codes her unmöglich sein sollte.

Umschalten des Zellstatus mit der Maus

Wenn die Simulation angehalten wird, können Sie die Zellen am unteren Rand des Gitters - die Zellen, die die neueste Generation darstellen - durch Anklicken umschalten.
const PRESSED_COLOR: Color = Color::YELLOW;

fn maybe_toggle_cells(
    timer: ResMut<EvolutionTimer>,
    mut history: ResMut<History>,
    mut interaction: Query<
        (&Interaction, &CellPosition, &mut BackgroundColor),
        (Changed<Interaction>, With<Button>)
    >
) {
    if !timer.is_running()
    {
        for (interaction, position, mut color) in &mut interaction
        {
            match *interaction
            {
                Interaction::Pressed =>
                {
                    let cell = &mut history[*position];
                    *cell = !*cell;
                    *color = liveness_color(*cell);
                },
                Interaction::Hovered =>
                {
                    *color = BackgroundColor(PRESSED_COLOR);
                },
                Interaction::None =>
                {
                    *color = liveness_color(history[*position]);
                }
            }
        }
    }
}
Wir haben hier eine viel komplexere Query, also lassen Sie uns diese analysieren. Es gibt zwei Typ-Parameter, jeweils ein Tupel. Der erste aggregiert die Zielkomponenten, der zweite aggregiert zusätzliche Filter. Interaction abstrahiert die Art der Interaktion, die an einem UI-Knoten stattgefunden hat. Changed schließt Entitäten aus, für die sich Interaction seit der letzten Ausführung dieses Systems nicht geändert hat. With<Button> stellt sicher, dass während der Iteration nur Entitäten mit der Komponente Button erscheinen. Und nun zur Logik. Wir ignorieren Mauseingaben, wenn der Timer läuft. Wenn der Timer läuft, läuft die Simulation, und wir wollen dem Benutzer nicht die frustrierende Erfahrung zumuten, gegen den Evolver anzutreten, um die Zellen anzuklicken. Wir führen eine Schleife über alle Interaktionen durch und ergreifen die entsprechenden Maßnahmen, wenn das UI-Element gedrückt, mit der Maus bewegt oder verlassen wird (Interaction::None). Dies bedeutet: die Zelle wird zwischen "an" und "aus" umgeschaltet, der leuchtend gelbe Interaktivitätsindikator wird angezeigt und die Zelle wird wieder in ihren normalen Zustand zurückversetzt, wenn Sie den Schwebezustand aufheben. Beachten Sie, dass wir das spezifische Gerät hier nie explizit erwähnen, denn Interaction ist aus der Sicht des UI-Elements, nicht aus der des Benutzers oder des Geräts. Das macht diesen Mechanismus technisch gesehen geräteunabhängig, auch wenn es sich bei dem Gerät normalerweise um ein Zeigegerät wie eine Maus oder ein Trackpad handelt.

Pufferung einer neuen Regel

In den meisten mathematischen Übungen entwickelt sich ein zellulärer Automat nach einer einzigen festen Regel, die zu Beginn festgelegt wird. Aber es ist eine viel ansprechendere visuelle Simulation, wenn Sie dem Benutzer erlauben, die Regel während der Entwicklung zu ändern. Bevor wir uns die beteiligten Systeme ansehen, lassen Sie uns einen Blick auf die Datenstruktur werfen, die sie unterstützt. Sie heißt AutomatonRuleBuilder. Wir haben sie als Ressource aus unserem Plugin hinzugefügt, sie aber völlig außer Acht gelassen. Es ist an der Zeit, sie in den Mittelpunkt zu rücken.
#[derive(Default, Resource)]
struct AutomatonRuleBuilder
{
    builder: Option<String>,
    timer: Option<Timer>
}
builder ist der Kompositionspuffer für die textuelle Version einer vom Benutzer eingegebenen Regel. Er wechselt von None zu Some, wenn der Benutzer die erste Ziffer der neuen Regel eintippt, entweder über die Zahlenreihe oder den Zahlenblock. Er wechselt zurück zu None, wenn entweder die gepaarte timer abläuft oder eine ungültige Regel erkannt wird. timer steuert den Rhythmus der Dateneingabe. Der Timer wechselt auch von None zu Some, wenn der Benutzer die erste Ziffer eingibt. Der Timer ist einmalig, wird aber zurückgesetzt, sobald der Benutzer eine weitere Ziffer eingibt. Wenn der Timer abläuft, wird der Inhalt des Puffers als vollständig behandelt und als AutomatonRule geparst. Nach dem gleichen Muster, das wir jetzt schon mehrmals gesehen haben, kreuzen wir auch den Timer für die Dateneingabe manuell an:
impl AutomatonRuleBuilder
{
    /// Update the [timer](Self::timer) by the specified [duration](Duration).
    #[inline]
    fn tick(&mut self, delta: Duration)
    {
        if let Some(ref mut timer) = self.timer
        {
            timer.tick(delta);
        }
    }
}
Wir erlauben nur die Akkumulation von Ziffern im Puffer:
const RULE_ENTRY_GRACE: Duration = Duration::from_millis(600);

impl AutomatonRuleBuilder
{
    fn push_digit(&mut self, c: char)
    {
        assert!(c.is_digit(10));
        match self.builder
        {
            None =>
            {
                self.builder = Some(c.into());
                self.timer = Some(
                    Timer::new(RULE_ENTRY_GRACE, TimerMode::Once)
                );
            },
            Some(ref mut builder) if builder.len() < 3 =>
            {
                builder.push(c);
                self.timer.as_mut().unwrap().reset();
            },
            Some(_) =>
            {
                self.builder = None;
                self.timer = None;
            }
        }
    }
}
Der Aufrufer ist dafür verantwortlich, push_digit eine tatsächliche Ziffer zu geben, also setzen wir die Vorbedingung durch. Wir richten einen Puffer und einen Timer ein, wenn diese nicht bereits vorhanden sind. Das bedeutet, dass dies die erste Ziffer ist, die wir jemals gesehen haben oder seit die letzte vorgeschlagene neue Regel akzeptiert oder abgelehnt wurde. Wenn der Puffer nach dem Anhängen der neuen Ziffer noch gültig sein könnte, schieben Sie ihn an und setzen den Timer zurück, um dem Benutzer ein volles Quantum (600ms) für die Eingabe der nächsten Ziffer zu geben. Wenn der Puffer nach dem Push offensichtlich ungültig wäre, d.h. weil jede gültige Parse die obere Grenze für einen Wolfram-Code überschreiten würde, dann zerstören Sie den Puffer und den Timer sofort.
impl AutomatonRuleBuilder
{
    fn buffered_input(&self) -> Option<&str> { self.builder.as_deref() }
}
Um direkt auf die gepufferte Eingabe zuzugreifen, rufen wir buffered_input auf. Wir können is_some verketten, um dies in eine einfache Prüfung auf das Vorhandensein von gepufferten Daten zu verwandeln. Schließlich müssen wir die Nachfolgeregel aus einer AutomatonRuleBuilder extrahieren:
impl AutomatonRuleBuilder
{
    fn new_rule(&mut self) -> Option<AutomatonRule>
    {
        match self.timer
        {
            Some(ref timer) if timer.just_finished() =>
            {
                let rule = match self.builder.as_ref().unwrap().parse::<u8>()
                {
                    Ok(rule) => Some(AutomatonRule::from(rule)),
                    Err(_) => None
                };
                self.builder = None;
                self.timer = None;
                rule
            }
            _ => None
        }
    }
}
Wir wollen die gepufferten Daten nur analysieren, wenn der Timer gerade abgelaufen ist. Wir geben Some nur zurück, wenn das Parsen erfolgreich war und zerstören auf dem Weg dorthin den Builder und den Timer.

Eingeben einer neuen Regel

Mit dem unterstützenden Modell im Rücken können wir nun getrost zur Untersuchung der beteiligten Systeme übergehen. Das erste ist accept_digit, das Tastatureingaben in gepufferte Ziffern umwandelt:
fn accept_digit(
    keys: Res<Input<KeyCode>>,
    mut builder: ResMut<AutomatonRuleBuilder>,
    mut next_rule: Query<&mut Style, With<NextRule>>
) {
    for key in keys.get_just_pressed()
    {
        match key.to_digit()
        {
            Some(digit) => builder.push_digit(digit),
            None => {}
        }
    }
    let style = &mut next_rule.single_mut();
    style.display =
        if builder.buffered_input().is_some() { Display::Flex }
        else { Display::None };
}
Nichts Neues in der Signatur und nichts furchtbar Neues im Hauptteil. Kurz gesagt, wir fügen eine Ziffer an den Puffer an und schalten die Sichtbarkeit des Overlays um. Wenden wir unsere Aufmerksamkeit kurz to_digit zu, denn es handelt sich um die einzige Eigenschaft, die wir in dem gesamten Projekt einführen:
const NUMBER_ROW_RANGE: RangeInclusive<u32> =
    KeyCode::Key1 as u32 ..= KeyCode::Key0 as u32;

const NUMPAD_RANGE: RangeInclusive<u32> =
    KeyCode::Numpad0 as u32 ..= KeyCode::Numpad9 as u32;

trait ToDigit: Copy
{
    fn to_digit(self) -> Option<char>;
}

impl ToDigit for KeyCode
{
    fn to_digit(self) -> Option<char>
    {
        if NUMBER_ROW_RANGE.contains(&(self as u32))
        {
            match self
            {
                KeyCode::Key0 => Some('0'),
                key => Some(char::from_digit(key as u32 + 1, 10).unwrap())
            }
        }
        else if NUMPAD_RANGE.contains(&(self as u32))
        {
            let delta = self as u32 - KeyCode::Numpad0 as u32;
            Some(char::from_digit(delta, 10).unwrap())
        }
        else
        {
            None
        }
    }
}
Der Übersetzungscode ist etwas unschön, weshalb wir ihn in einer Hilfseigenschaft außerhalb der Haupterzählung verstecken. Key0 kommt nach Key9, so wie es auf der Zahlenreihe der Tastatur erscheint, daher die etwas seltsame Logik in match. Aber Numpad0 kommt vor Numpad1, daher ist dieser Fall einfacher. Wir machen uns die Tatsache zunutze, dass ein Enum mit ist, damit wir mit den Enum-Diskriminanten arithmetisch rechnen können. Schließlich verwenden wir char::from_digit, um das Druckzeichen zu erhalten, das dem Tastendruck entspricht. Wir müssen auch die Beschriftung innerhalb des neuen Overlays ändern, wenn und nur wenn sie sichtbar ist:
fn update_next_rule(
    builder: Res<AutomatonRuleBuilder>,
    mut next_rule: Query<&mut Text, With<NextRuleLabel>>
) {
    let buffered_input = builder.buffered_input();
    if buffered_input.is_some()
    {
        let text = &mut next_rule.single_mut();
        text.sections[1].value = match builder.buffered_input()
        {
            Some(rule) if rule.parse::<u8>().is_ok() => rule.to_string(),
            _ => "Error".to_string()
        };
    }
}
Wir erhalten den Abschnitt Text über die Query, damit wir den dynamischen Abschnitt unter dem Index 1 aktualisieren können. Wir führen die Aktualisierung nur durch, wenn einige Ziffern gepuffert sind. Wenn das Parsen der gepufferten Daten als Wolfram-Code fehlschlägt, dann aktualisieren Sie die Beschriftung vorübergehend auf "Fehler". Über ein drittes System aktualisieren wir schließlich die Ressource AutomatonRule:
fn maybe_change_rule(
    time: Res<Time>,
    mut rule: ResMut<AutomatonRule>,
    mut builder: ResMut<AutomatonRuleBuilder>,
    mut query: Query<&mut Window>
) {
    builder.tick(time.delta());
    match builder.new_rule()
    {
        Some(new_rule) =>
        {
            *rule = new_rule;
            let window = &mut query.single_mut();
            set_title(window.as_mut(), *rule);
        },
        None => {}
    }
}
Wir ticken die Uhr von AutomatonRuleBuilder manuell und versuchen dann, eine neue Regel aus der gepufferten Eingabe zu extrahieren. Wir haben Zugriff auf die einsame Window, so dass wir den Titel aktualisieren können, um eine neue Regel wiederzugeben. Durch die Verwendung unserer plattformübergreifenden set_title stellen wir sicher, dass dies sowohl auf nativen Systemen als auch im Web funktioniert.

FPS melden

Fast fertig! Jetzt müssen Sie nur noch die momentanen Bilder pro Sekunde anzeigen, während der Benutzer die rechte Umschalttaste gedrückt hält:
fn maybe_show_fps(
    keys: Res<Input<KeyCode>>,
    mut fps: Query<&mut Style, With<Fps>>
) {
    let style = &mut fps.single_mut();
    style.display = match keys.pressed(KeyCode::ShiftRight)
    {
        true => Display::Flex,
        false => Display::None
    };
}
Wir haben bereits kurz über die einzige andere Neuerung gesprochen. pressed antwortet so lange auf true, wie die rechte Umschalttaste gedrückt bleibt. Die Anzeige wird also Flex, wenn der Benutzer die Taste drückt und bleibt Flex, bis der Benutzer die Taste loslässt. Dadurch wird die Sichtbarkeit des Overlays direkt an die Dauer des Tastendrucks gekoppelt.
fn update_fps(
    diagnostics: Res<DiagnosticsStore>,
    mut fps: Query<&mut Text, With<FpsLabel>>
) {
    let text = &mut fps.single_mut();
    let fps = diagnostics.get(FrameTimeDiagnosticsPlugin::FPS).unwrap();
    if let Some(value) = fps.smoothed()
    {
        text.sections[1].value = format!("{:.2}", value);
    }
}
DiagnosticsStore ist unser Zugang zu den verschiedenen Diagnosen, die Bevy während der Lebensdauer unserer Anwendung aufrechterhält. Wir haben FrameTimeDiagnosticsPlugin hinzugefügt, damit die Bildrate auch über DiagnosticsStore verfügbar ist. Wir extrahieren die gewünschte Diagnostic nach der ID, die in diesem Fall treffend FPS heißt. smoothed holt den vorberechneten exponentiell gewichteten gleitenden Durchschnitt (EWMA) für die Diagnose. Wir beschränken das Ergebnis auf zwei Dezimalstellen und aktualisieren den dynamischen Abschnitt des Textes unter dem Index 1.

Fazit

Nun, wie man heutzutage sagt, das war eine Menge. Aber ich glaube, dass eine Demonstration angebracht ist. Wir können kaum abschließen, ohne das alles in Aktion zu sehen, oder? Schalten wir also kurzerhand eine einzelne Zelle ein und sehen wir zu, wie Regel #90 ein berühmtes Fraktal erzeugt: das Sierpiński-Dreieck! [video width="2048" height="1592" mp4="https://xebia.com/wp-content/uploads/2024/02/Rule-90-Evolution.mp4"][/video] Sie können diese Demonstration auch interaktiv erleben, wenn Sie dies bevorzugen. Möglicherweise müssen Sie auf das Gitter klicken, um der Anwendung den Fokus zu geben, bevor sie Eingaben mit der Tastatur oder der Maus akzeptiert. Wir haben uns mit der Geschichte der Mathematik, der Informatik, der Rust-Programmierung und der grundlegenden Spieleentwicklung beschäftigt. Wir hoffen, dass dies der Anfang einer Reise ist, nicht das Ende. Wir haben lediglich Ihren Appetit darauf geweckt, mit Rust fantastisch zu programmieren. Es gibt so viele weitere Themen und Kisten und so viel mehr zu tun, allein mit Bevy. Vielen Dank für Ihre Zeit, und viel Spaß beim Programmieren!

Verfasst von

Todd Smith

Rust Solution Architect at Xebia Functional. Co-maintainer of the Avail programming language.

Contact

Let’s discuss how we can support your journey.