Im ersten Beitrag dieser dreiteiligen Serie haben wir elementare zelluläre Automaten beschrieben und sie in der Programmiersprache Rust modelliert. In diesem Beitrag, dem zweiten der dreiteiligen Serie, beschreiben wir die Theorie, die der Entity-Component-System (ECS)-Architektur zugrunde liegt, wie die Spiele-Engine Bevy dies in der Praxis umsetzt, wie Sie Bevy für die plattformübergreifende Entwicklung einrichten und optimieren und wie Sie mit Bevy eine statische Benutzeroberfläche erstellen. Vielleicht möchten Sie das vollständig ausgearbeitete Projekt in der Hand halten, während Sie lesen.
Entitäts-Komponenten-System (ECS) Architektur
Wir haben mit der Theorie begonnen, sind dann zur Praxis übergegangen und jetzt ist es Zeit für etwas mehr Theorie. Bevor wir uns in die Verwendung von Bevy stürzen, sollten wir zunächst einen Boxenstopp einlegen, um etwas über die ECS-Architektur ( Entity-Component-System ) zu erfahren.
In der ECS-Architektur unterwirft ein diskreter Ereignissimulator zahlreiche Entitäten Systemen, die ihre Lebenszyklen steuern und ihre Interaktionen durch Operationen an ihren zustandsabhängigen Komponenten vermitteln.
- Eine Entität ist ein undurchsichtiges Atom der Identität. Sie hat in der Regel keine intrinsischen Eigenschaften und kann in der Regel durch eine einfache Ganzzahl dargestellt werden.
- Eine Komponente weist einer Entität eine Rolle zu und kapselt alle Daten, die zur Modellierung dieser Rolle erforderlich sind. Komponenten können dauerhaft oder vorübergehend an Entitäten angebracht werden und werden in der Regel extrinsisch verwaltet, d.h. sie werden über eine externe Datenstruktur auf eine Entität abgebildet. In einer Physiksimulation könnte eine "Starrkörper"-Komponente jede Entität zieren, die ein physikalisches Objekt darstellt. Die "Starrkörper"-Komponente könnte Zustände zur Modellierung von Masse, linearem Widerstand, Winkelwiderstand usw. enthalten.
- Ein System verkörpert einen Prozess, der nur auf Entitäten wirkt, die augenblicklich eine bestimmte Zielkombination von Komponenten besitzen. Systeme können: Entitäten in die Simulation injizieren, Entitäten aus der Simulation löschen, Komponenten an Entitäten anhängen, Komponenten von Entitäten lösen, den Zustand innerhalb von Komponenten ändern, globale Ressourcen verwalten und Schnittstellen zu anderen Anwendungsmodulen bilden.
ECS ist in Videospielen und Simulationen weit verbreitet, funktioniert aber immer dann gut, wenn Anwendungen auf datenorientierten Designprinzipien beruhen. Es passt gut zu anderen Paradigmen wie der objektorientierten oder funktionalen Programmierung, da es einen orthogonalen Ansatz zur Lösung verwandter Probleme der Struktur und Komposition verfolgt.
Bevy
Bevy ist eine datengesteuerte Spiel-Engine mit einem schnellen, flexiblen ECS. Sie ist relativ neu, aber auch leistungsstark und plattformübergreifend. Sie unterstützt 2D- und 3D-Rendering-Pipelines, Szenenpersistenz, Cascading Style Sheets (CSS) und Hot Reloading. Sein Build-System ermöglicht eine schnelle Neukompilierung, so dass Sie mehr Zeit mit Testen als mit Warten verbringen. Außerdem lässt es sich reibungslos in zahlreiche beliebte Crates wie Serde (für die Serialisierung) und egui (für die Erstellung von grafischen Benutzeroberflächen im Sofortmodus) integrieren. Wir werden in diesem Projekt kaum an der Oberfläche kratzen, was Bevy alles kann.
Die Entitäten von Bevy sind Generationsindizes. Seine Komponenten sind structs und enums: gewöhnliche Datentypen, für die Sie die Eigenschaft Component implementieren können, was Sie in der Regel einfach durch Ableitung von Component tun. Die Systeme sind gewöhnliche Funktionen, deren Signaturen aus Typen aufgebaut sind, die die Eigenschaft SystemParam implementieren. Diese Typen werden vom Bevy-Framework zur Verfügung gestellt, und viele von ihnen sind generisch über (Ihre eigenen) Komponententypen.
Wenn Ihnen das zu abstrakt ist, machen Sie sich keine Sorgen. Wir werden es Stück für Stück mit konkreten Beispielen zusammensetzen.
Einrichten von plattformübergreifendem Bevy
Lassen Sie uns Bevy sowohl für die native als auch für die Webentwicklung und -bereitstellung einrichten. Wir gehen Schritt für Schritt vor, aber wenn Sie weitere Anleitungen benötigen, können Sie sich die offiziellen oder inoffiziellen Tipps zur Einrichtung von Bevy ansehen.
In
Cargo.toml
fügen wir nicht nur eine, sondern gleich zwei Abhängigkeiten für Bevy hinzu.
[dependencies.bevy]
version ="0.12.0"
[target.'cfg(not(target_family = "wasm"))'.dependencies.bevy]
version ="0.12.0"
features =["dynamic_linking"]
Der erste Abschnitt bringt Bevy mit den Standardfunktionen in das Projekt ein, sofern es keine Überschreibung für eine spezifischere Konfiguration gibt. Der zweite Abschnitt ist natürlich eine solche Überschreibung. Diese Überschreibung ermöglicht insbesondere die dynamische Verknüpfung der Bevy-Kiste, wodurch der Entwicklungszyklus Ihrer Anwendung beschleunigt wird. Dynamisches Linking ist nur für native Ziele verfügbar, nicht für WebAssembly (WASM), daher die Konditionalität.
Jetzt müssen wir Cargo anweisen, von der dynamischen Verknüpfung zu profitieren. Unter .cargo/config.tomlstellen wir Ihnen die verschiedenen plattformspezifischen Konfigurationen zur Verfügung.
[target.x86_64-unknown-linux-gnu]
linker ="clang"
rustflags =["-Clink-arg=-fuse-ld=lld"]
[target.x86_64-apple-darwin]
rustflags =[
"-C",
"link-arg=-fuse-ld=/usr/local/opt/llvm/bin/ld64.lld"
]
[target.aarch64-apple-darwin]
rustflags =[
"-C",
"link-arg=-fuse-ld=/opt/homebrew/opt/llvm/bin/ld64.lld"
]
[target.x86_64-pc-windows-msvc]
linker ="rust-lld.exe"
[target.wasm32-unknown-unknown]
runner ="wasm-server-runner"
Wir unterstützen Linux, macOS und Windows auf x86; macOS auf AArch64; und Web auf WASM. Wie von den Leuten hinter Bevy empfohlen, verwenden wir lld, dem Linker, der mit LLVM geliefert wird. Sie müssen lld nicht unbedingt installieren, aber es wird empfohlen, um die schnellste Linkleistung zu erzielen, was sich direkt in einer kürzeren Wartezeit auf die Fertigstellung des Builds niederschlägt. Wenn Sie lld noch nicht haben und ihn auch nicht installieren möchten, können Sie die Pfade zu Ihrem bevorzugten Linker einfach ersetzen. Wenn Sie lld installieren möchten, können Sie die von Bevy bereitgestellten Installationsanweisungen befolgen.
Der Schlüssel runner im WASM-Abschnitt am Ende gibt ein Cargo-Plugin an, wasm-server-runner, mit dem Sie cargo run --target wasm32-unknown-unknown zum Testen eines WASM-Builds verwenden können. Sie können es mit cargo install wasm-server-runner installieren.
Es hat ein bisschen gedauert, aber jetzt ist Bevy einsatzbereit - auf fünf Plattformen.
Plattformübergreifende Programmargumente
Es wäre schön, wenn der Benutzer die Anfangsbedingungen für die Entwicklung festlegen könnte. Es gibt zwei interessante Konfigurationsparameter:
- Die erste Generation des zellulären Automaten.
- Die Regel, nach der sich der zelluläre Automat von einer Generation zur nächsten weiterentwickelt: .
Wie wir oben gesehen haben, können wir einen zellulären Automaten mit einem u64 und eine Regel mit einem u8 beschreiben und from verwenden, um unsere Modelltypen zu erhalten. Aber das Parsen von Befehlszeilenargumenten ist so komplex, dass wir diese Aufgabe an eine ausgereifte Kiste eines Drittanbieters delegieren möchten: Clap. Lassen Sie uns diese in das Projekt einbinden, indem wir dies zu Cargo.toml:
[target.'cfg(not(target_family = "wasm"))'.dependencies.clap]
version ="4.4.8"
features =["derive"]
Zurück in
src/main.rs
bündeln wir unsere Konfigurationsparameter in einer Struktur mit einer deklarativen Strategie und lassen Clap die harte Arbeit für uns erledigen:
/// Fun with cellular automata! Set the first generation with a known seed
/// and/or rule, or let the program choose randomly. Watch the automaton evolve,
/// and influence its evolution with the keyboard and mouse.
#[derive(Debug,Default)]
#[cfg_attr(not(target_family ="wasm"), derive(Parser))]
structArguments
{
/// The rule, specified as a Wolfram code between 0 and 255, inclusive. If
/// unspecified, the rule will be chosen randomly.
#[cfg_attr(not(target_family ="wasm"), arg(short, long))]
rule:Option<u8>,
/// The first generation, specified as a 64-bit integer that represents the
/// complete population. Lower numbered bits correspond to cells on the
/// right of the visualization. If unspecified, the first generation will be
/// chosen randomly.
#[cfg_attr(not(target_family ="wasm"), arg(short, long))]
seed:Option<u64>
}
Es gibt eine offensichtliche Komplexität, also lassen Sie uns auspacken:
- Wir leiten
DebugundDefaultab, denn beide sind praktisch. - Wenn das Ziel nicht WASM ist, leiten wir
clap::Parserab, das alle notwendigen Boilerplates generiert, um unsere Argumente aus der Befehlszeile zu parsen. - Wenn das Ziel nicht WASM ist, liefern wir das Attribut
argaus der DateiClapKiste. Dies gibt dem generierten Parser eine Kurzform, eine Langform und eine Beschreibung des Arguments vor. Die Beschreibung stammt direkt aus dem Doku-Kommentar , weshalb ich sie in den Auszug aufgenommen habe. Sie sollten sich nicht auf eine ausgefallene Rustdoc-Formatierung verlassen, denn Clap gibt diese Formatierung direkt in die Standardausgabe aus, wenn das Programm mit--helpausgeführt wird. ruleundseedsind beide optional. Was immer der Benutzer nicht angibt, wird zufällig ausgewählt.- Clap gibt auch den Dokumentenkommentar für die Struktur selbst als Zusammenfassung des Programms aus. Es gelten also dieselben Vorbehalte wie oben; halten Sie es einfach und sprechen Sie den Benutzer direkt an.
Das gilt für den nativen Fall, aber im Web gibt es keine Befehlszeilenargumente. Es verfügt jedoch über einen Abfrage-String mit Suchparametern, die eine ähnliche Rolle wie Befehlszeilenargumente spielen. Wir gehen rüber zu Cargo.tml ein weiteres Mal, um eine bedingte Abhängigkeit von web-sys zu registrieren:
[target.'cfg(target_family = "wasm")'.dependencies.web-sys]
version ="0.3.65"
features =["Location","Url","UrlSearchParams"]
web-sys partitioniert die enorme Web-API mithilfe von Crate-Features. Wir müssen auf die Typen Location, Url und UrlSearchParams zugreifen, um unseren eigenen einfachen Parser für Suchparameter zu erstellen, also geben wir die gleichnamigen Funktionen an.
Oh, wenn wir uns schon die Build-Datei ansehen, können wir auch gleich noch eine Sache erledigen. Wir haben die Zufallsgenerierung versprochen, also lassen Sie uns die rand Kiste einfügen, um das zu erledigen. Wir fügen sie direkt vor ringbuffer ein, um die Dinge alphabetisch zu ordnen.
[dependencies]
rand ="0.8.5"
ringbuffer ="0.15.0"
Wir können die beiden Fälle jetzt implementieren. Zurück zu src/main.rs jetzt! Für native verpacken wir das Ergebnis von Clap-generated parse einfach in eine Some:
#[cfg(not(target_family ="wasm"))]
fn arguments()->Option<Arguments>
{
Some(Arguments::parse())
}
Für das Web machen wir ein bisschen mehr Arbeit, aber es ist eng an die Web-APIs angelehnt:
#[cfg(target_family ="wasm")]
fn arguments()->Option<Arguments>
{
let href = web_sys::window()?.location().href().ok()?;
let url = web_sys::Url::new(&href).ok()?;
let params = url.search_params();
let rule = params.get("rule").and_then(|rule| rule.parse().ok());
let seed = params.get("seed").and_then(|seed| seed.parse().ok());
Some(Arguments{ rule, seed })
}
Wir haben die Funktion arguments in beiden Fällen aufgerufen und darauf geachtet, ihr dieselbe Signatur zu geben, so dass wir denselben Namen und dieselben Konventionen verwenden können, um sie im nativen und im Web aufzurufen.
Kontrolle an Bevy abgeben
Wir haben jetzt die Argumente, also ist es an der Zeit, sie zu verwenden. Sehen wir uns an, wie main Bevy initialisiert und die Kontrolle an die Motorschleife übergibt.
fn main()
{
let args = arguments().unwrap_or(Arguments::default());
let rule = args.rule
.and_then(|rule|Some(AutomatonRule::from(rule)))
.unwrap_or_else(|| random::<u8>().into());
let seed = args.seed
.and_then(|seed|Some(Automaton::<AUTOMATON_LENGTH>::from(seed)))
.unwrap_or_else(|| random::<u64>().into());
App::new()
.insert_resource(
History::<AUTOMATON_LENGTH, AUTOMATON_HISTORY>::from(seed)
)
.insert_resource(rule)
.add_plugins(AutomataPlugin)
.run();
}
Hier sehen wir den Aufruf von arguments, der je nach Kompilierungsziel an die richtige Implementierung gebunden ist. Wie versprochen, ist der Aufruf nichts Besonderes - es handelt sich um einen ganz normalen Funktionsaufruf. Wenn der Aufruf aus irgendeinem Grund fehlschlägt, setzen wir den Standard Arguments ein, der dafür sorgt, dass sowohl die Regel als auch die erste Generation randomisiert werden.
App ist unser Eingangstor zum Bevy-Framework. Nach der Initialisierung werden wir uns nicht mehr direkt darauf beziehen, aber es enthält die Welt, den Runner und die Plugins. Die Welt ist die komplette Sammlung der Systemelemente, aus denen das Anwendungsmodell besteht. Der Runner ist die Hauptschleife, die Benutzereingaben verarbeitet, die Welt im Laufe der Zeit weiterentwickelt und das Rendering steuert. Und Plugins sind vorgefertigte Mini-Welten: Sammlungen von Ressourcen und Systemen, die in vielen Projekten wiederverwendet werden können.
Eine Ressource ist ein globales Singleton mit einem eindeutigen Typ. Systeme greifen auf Ressourcen über Dependency Injection zu. Wir verwenden Resource ableitet, kann als Ressource verwendet werden. Im ersten Blogbeitrag dieser Serie haben wir Resource sowohl für AutomatonRule als auch für History abgeleitet, und jetzt wissen Sie, warum!
AutomataPlugin ist das Plugin, das alle unsere anderen Ressourcen und Systeme bündelt. Wir binden es über add_plugins an. Schließlich rufen wir run auf, um die Kontrolle an Bevy zu übergeben. Von nun an ist die Hauptschleife der Engine für die gesamte Ausführung verantwortlich.
Modulare Komposition mit Plugins
Vielleicht überrascht es Sie, dass unser Plugin völlig zustandslos ist. Drüben in src/ecs.rswo wir den Rest unserer Zeit verbringen werden, sehen wir:
pubstructAutomataPlugin;
Zustandslos ist in Ordnung, denn wir interessieren uns nur für das Verhalten des Plugins, nämlich die Initialisierung der Anwendung zu beenden. Dafür implementieren wir die Eigenschaft Plugin:
implPluginforAutomataPlugin
{
fn build(&self, app:&mutApp)
{
let _seed = app.world.get_resource::<History>()
.expect("History resource to be inserted already");
let rule = app.world.get_resource::<AutomatonRule>()
.expect("AutomatonRule resource to be inserted already");
letmut window =Window{
resolution:[1024.0,768.0].into(),
title: rule.to_string(),
..default()
};
set_title(&mut window,*rule);
app
.add_plugins(DefaultPlugins.set(WindowPlugin{
primary_window:Some(window),
..default()
}))
.add_plugins(FrameTimeDiagnosticsPlugin)
.insert_resource(EvolutionTimer::default())
.insert_resource(AutomatonRuleBuilder::default())
.add_systems(Startup, add_camera)
.add_systems(Startup, build_ui)
.add_systems(Update, maybe_toggle_instructions)
.add_systems(Update, accept_digit)
.add_systems(Update, maybe_show_fps)
.add_systems(Update, maybe_toggle_cells)
.add_systems(Update, update_next_rule)
.add_systems(Update, maybe_change_rule)
.add_systems(Update, evolve)
.add_systems(Update, update_fps);
}
}
Es ist nicht erforderlich, dass ein Plugin zustandslos ist, also leiht sich build sowohl das Plugin als auch App. Wir verwenden das statisch polymorphe get_resource, um den Seed und die Regel zu extrahieren, die wir in main registriert haben. Beachten Sie, dass wir diese Ressourcen nur über ihre statischen Typen ziehen, weshalb jede Ressource einen eindeutigen statischen Typ benötigt. Das ist kein Problem, denn wenn wir z.B. 20 Strings registrieren wollen, können wir jeden zuerst in einen eigenen neuen Typ verpacken. Neue Typen haben keine Laufzeitkosten und bieten auch eine bessere Semantik, so dass diese Einschränkung uns zu besseren Modellierungsentscheidungen führt. Wir verwenden den Seed überhaupt nicht, aber seine Verfügbarkeit ist eine wichtige Voraussetzung für die Installation unseres Plugins, also extrahieren wir ihn trotzdem.
Wir verwenden die Regel, um den Titel für Window festzulegen. Auf nativen Systemen betrifft dies die Titelleiste des Fensters. Aber in WASM wird Window auf einen Canvas abgebildet, der keine Titelleiste hat. Wir brauchen einen plattformübergreifenden Mechanismus, um dies richtig zu handhaben, also werden wir weiter unten darauf zurückkommen.
DefaultPlugins fasst die Standard-Plugins zusammen, die in den meisten Projekten nützlich sind:
LogPlugin, ein Logging-Plugin, das auf der beliebtentracing-subscribercrate aufbaut.TaskPoolPluginfür die Verwaltung von AufgabenpoolsTypeRegistrationPlugin, die eine Low-Level-Unterstützung für die typbasierte Ressourcenregistrierung bietet, die wir oben gesehen habenFrameCountPluginzum Zählen von RahmenTimePlugin, die Unterstützung für diskrete Zeit und Timer bietetTransformPlugin, um die Platzierung und Transformation von Entitäten zu ermöglichenHierarchyPluginfür den Aufbau von KomponentenhierarchienDiagnosticsPluginfür die Erfassung verschiedener Ausführungs- und LeistungsmetrikenInputPlugin, die den Zugriff auf Tastatur-, Maus- und Gamepad-Eingaben ermöglichtWindowPluginfür plattformübergreifende FensterunterstützungAccessibilityPluginein Plugin zur Verwaltung und Koordinierung von Integrationen mit accessibility APIs
Mit der Methode set auf DefaultPlugins können wir eines dieser Plugins ersetzen. Wir stellen window manuell zur Verfügung, das wir bereits erstellt und angepasst haben und das als Hauptfenster für die Anwendung dienen soll.
Nachdem wir die grundlegenden Plugins hinzugefügt haben, fügen wir zwei weitere Ressourcen ein, eine zur Verwaltung der Evolutionsrate und die andere zum Puffern von Benutzereingaben bei der Eingabe einer neuen Regel. Schließlich fügen wir alle Systeme ein, die zusammen das Verhalten unserer Anwendung bestimmen. Bevy fasst die Systeme in vordefinierten Zeitplänen zusammen. Der Zeitplan Startup läuft genau einmal, und zwar während der Initialisierung der Anwendung, so dass hier Systeme aufgezeichnet werden können, die eine einmalige Einrichtungslogik ausführen. Der Zeitplan Update wird einmal pro Iteration der Engine-Schleife ausgeführt. add_systems verknüpft ein System mit einem Zeitplan und integriert diese Verknüpfung in die Welt.
Einstellen des Fenstertitels
Bevor wir uns mit den verschiedenen Systemen befassen, lassen Sie uns einen kurzen Umweg über die Fenstertitel machen, um eine Katharsis zu erreichen. Wir haben die Logik auf set_title abstrahiert, damit wir das Verhalten für native und Web-Anwendungen unterschiedlich gestalten können.
Die native Implementierung ist recht trivial. Wir haben bereits eine Window und die Window hat eine Titelleiste, also ist es eine einfache Sache, ein Feld zu aktualisieren:
#[cfg(not(target_family ="wasm"))]
fn set_title(window:&mutWindow, rule:AutomatonRule)
{
window.title = rule.to_string();
}
Die Web-Implementierung ist nicht viel schwieriger, aber Sie müssen sich daran erinnern, wie das Web funktioniert. Die Wurzel des Namensraums ist window, der ein komplettes Browserfenster enthält. Ein Fenster hat eine document, die die Wurzel für die Seitenknoten ist, die gemäß dem Document Object Model (DOM) des Webs organisiert sind. Ein Dokument hat einen Titel, der in der Registerkarte des Dokuments oder in der Titelleiste des Fensters (bei seltenen Browsern ohne Registerkarten) angezeigt wird. web-sys modelliert die Web-APIs sehr genau, so dass wir diese Überwachungskette direkt verfolgen können:
#[cfg(target_family ="wasm")]
fn set_title(_window:&mutWindow, rule:AutomatonRule)
{
web_sys::window().unwrap().document().unwrap().set_title(&rule.to_string());
}
unwrap ist hier sicher, weil unsere Host-Anwendung ein Webbrowser ist. Beide Aufrufe würden nur bei einem Headless-Host wie Node.js fehlschlagen, bei dem ein grafischer zellulärer Automatensimulator nicht einmal Sinn machen würde.
Kamera
Bevy ist eine Spiel-Engine, die mehrere Szenen unterstützt. Sie dient also einem breiteren, allgemeineren Zweck als ein gewöhnliches UI-Toolkit. Entitäten erscheinen nicht einfach in unserem Fenster, weil wir sie platzieren, sondern wir müssen sie durch eine Kamera beobachten. Wenn Sie keine Kamera hinzufügen, werden Sie auf ein schwarzes Fenster starren.
fn add_camera(mut commands:Commands)
{
commands.spawn(Camera2dBundle::default());
}
add_camera ist ein System, das wir dem Zeitplan Startup hinzugefügt haben. Sein Argument Commands ist unsere Schnittstelle zur Bevy-Befehlswarteschlange, mit der wir Entitäten erzeugen, Komponenten zu Entitäten hinzufügen, Komponenten von Entitäten entfernen und Ressourcen verwalten können.
spawn erstellt eine neue Entität, an die die angegebene Komponente angehängt wird. Das Argument kann alles sein, solange es ein Bundle darstellt. Ein Bundle ist einfach ein Stapel von Komponenten, und jede Komponente kann als ein Stapel von einer Komponente aufgefasst werden. Was die Eigenschaften betrifft, so erwartet spawn eine Implementierung der Eigenschaft Bundle, und jeder Typ, der die Eigenschaft Component implementiert, implementiert automatisch auch die Eigenschaft Bundle. Bevy implementiert Bundle für Tupel von Komponenten, so dass es praktisch ist, eine Entität mit mehreren angehängten Komponenten zu erzeugen.
Camera2dBundle fasst die vielen Komponenten zusammen, die zusammen eine Ansicht auf eine Szene ergeben. Die Standardinstanz bietet eine orthografische Projektion, so dass Linien und Parallelität auf Kosten von Abständen und Winkeln erhalten bleiben. Für unsere Zwecke stellt dies sicher, dass alle Zellen kongruent erscheinen, unabhängig von ihrem Abstand zum Objektiv.
Die Benutzeroberfläche
In unserer Anwendung gibt es im Wesentlichen vier Elemente der Benutzeroberfläche:
- Am offensichtlichsten ist das Gitter aus Zellen, das die Geschichte des zellulären Automaten darstellt. Wie bereits erwähnt, besteht jeder Automat aus 64 Zellen, und wir behalten 50 Generationen bei. Jede Zelle hat einen schwarzen Rand und ist schwarz ausgefüllt, wenn sie "an" ist und weiß, wenn sie "aus" ist. Die unterste Zeile stellt die neueste Generation von dar, so dass die Generationen im Laufe der Zeit von unten nach oben durchlaufen, während die Evolution von abläuft. Wir möchten, dass der Benutzer die neueste Generation zwischen "an" und "aus" umschalten kann. Daher verwenden wir anklickbare Schaltflächen für die letzte Zeile und füllen die Schaltfläche mit gelber Farbe, wenn sie mit dem Mauszeiger bewegt wird, als erkennbaren Hinweis auf Interaktivität. Keine der anderen Zellen ist interaktiv, so dass einfache Rechtecke ausreichen.
- Das halbtransparente Banner am oberen Rand des Rasters enthält abgekürzte Anweisungen, die den Benutzer zu den unterstützten Tastaturinteraktionen führen. Dieses Banner wird nur angezeigt, wenn der Simulator angehalten wird. Natürlich beginnt der Simulator in der Pause, so dass der Benutzer das Banner sehen und etwas darüber erfahren kann, welche Verhaltensweisen unterstützt werden.
- Das halbtransparente Banner unten links zeigt die nächste Regel an, die ausgeführt wird. Dieses Banner erscheint, wenn der Benutzer eine Ziffer drückt, entweder in der Zahlenreihe oder auf dem Ziffernblock, bleibt auf dem Bildschirm, während der Benutzer weitere Ziffern eingibt, und verschwindet, wenn der Benutzer fertig ist. Wenn der Benutzer also "121" eingibt, wird auf dem Banner zunächst "1", dann "12" und schließlich "121" angezeigt. Wenn der Benutzer eine ungültige Regelnummer eintippt, wie z.B. "500", dann wird auf dem Banner "Fehler" angezeigt.
- Das halbtransparente Banner unten rechts zeigt die momentanen Frames pro Sekunde (FPS), d.h. die Rendering-Rate für die grafische Pipeline, d.h. wie oft die Ansicht neu gezeichnet wird. Die Iterationsrate für die Engine-Schleife wird in Ticks pro Sekunde (TPS) gemessen, wobei ein Tick eine einzelne Iteration ist. Einige Spiel-Engines trennen diese beiden Konzepte, aber Bevy verbindet sie direkt miteinander, also $FPS = TPS$. FPS liefert uns also eine grobe Leistungskennzahl. Dieses Banner erscheint nur, wenn der Benutzer die rechte Umschalttaste gedrückt hält.
Das System build_ui gehört zum Plan Startup. Wir werden die UI-Elemente nur einmal erstellen und sie dann an Ort und Stelle von unseren Systemen mutieren lassen. Nur der Aufrufgraph mit der Wurzel build_ui wird Entitäten erzeugen, und diese Entitäten bleiben bestehen, bis die Anwendung beendet wird.
fn build_ui(history:Res<History>,mut commands:Commands)
{
commands
.spawn(NodeBundle{
style:Style{
height:Val::Percent(100.0),
width:Val::Percent(100.0),
..default()
},
background_color:BackgroundColor(Color::DARK_GRAY),
..default()
})
.with_children(|builder|{
build_history(builder,&history);
build_instruction_banner(builder);
build_next_rule_banner(builder);
build_fps_banner(builder);
});
}
Dieses System erhält ebenfalls Zugriff auf die Befehlswarteschlange, aber es geschieht etwas Neues. Res ist der Injektionspunkt für die History, die wir als Ressource registriert haben. Res verhält sich wie ein unveränderliches Borgen, das uns hier Lesezugriff auf die gesamte History gewährt. Allein durch die Angabe dieses Parameters weiß Bevy statisch, dass es die History, die wir registriert haben, injizieren muss. Natürlich kann es passieren, dass man vergisst, eine Ressource zu registrieren. In diesem Fall wird Bevy zur Laufzeit in Panik geraten, bevor ein System aufgerufen wird, das die fehlende Ressource benötigt. In der Regel finden Sie solche Probleme sofort, wenn Sie die Anwendung ausführen. Es ist also nicht weiter schlimm, dass die Überprüfung der Registrierung zur Laufzeit erfolgt.
build_ui legt eine Entität an, die die gesamte Benutzeroberfläche darstellt. Diese Entität dient als Wurzel der Hierarchie, die die vier oben erwähnten Hauptelemente enthält, von denen jedes seine eigenen Unterelemente umfasst. NodeBundle ist der Komponententyp, der als grundlegendes UI-Element dient. Style unterstützt eine beträchtliche Teilmenge der Funktionen von Cascading Style Sheets (CSS), einschließlich Flexbox und Grid. Hier stellen wir sicher, dass das Element den gesamten verfügbaren Platz im Fenster einnimmt.
Bevy übergibt eine ChildBuilder an with_children, die eine hierarchische Zusammensetzung von Entitäten ermöglicht. Wir geben es an jeden unserer untergeordneten UI-Element-Builder weiter.
Ansicht Geschichte
In der Build-Historie legen wir ein Raster an, das die Entwicklung unseres zellulären Automaten über die letzten fünfzig Generationen visualisiert:
fn build_history(builder:&mutChildBuilder, history:&History)
{
builder
.spawn(NodeBundle{
style:Style{
display:Display::Grid,
height:Val::Percent(100.0),
width:Val::Percent(100.0),
aspect_ratio:Some(1.0),
padding:UiRect::all(Val::Px(24.0)),
column_gap:Val::Px(1.0),
row_gap:Val::Px(1.0),
grid_template_columns:RepeatedGridTrack::flex(
AUTOMATON_LENGTH as u16,1.0),
grid_template_rows:RepeatedGridTrack::flex(
AUTOMATON_HISTORY as u16,1.0),
..default()
},
background_color:BackgroundColor(Color::DARK_GRAY),
..default()
})
.with_children(|builder|{
for(row, automaton) in history.iter().enumerate()
{
for(column, is_live) in automaton.iter().enumerate()
{
cell(builder,CellPosition{ row, column },*is_live);
}
}
});
}
Wir verwenden CSS Grid, um sicherzustellen, dass die Zellen eine einheitliche Größe haben. In der Schließung, die an with_children übergeben wird, iterieren wir durch den gesamten Verlauf, um die Zellen auszugeben. CellPosition ist eine benutzerdefinierte Komponente:
#[derive(Copy,Clone,Debug,Component)]
structCellPosition
{
row: usize,
column: usize
}
Genauso wie die Ableitung Resource ausreicht, um die Verwendung eines Typs als Ressource zu ermöglichen, reicht die Ableitung Component aus, um die Verwendung eines Typs als Komponente zu ermöglichen. Wie die Platzierungsschleife veranschaulicht, wächst row von oben nach unten, während column von links nach rechts wächst.
fn cell(builder:&mutChildBuilder, position:CellPosition, live: bool)
{
builder
.spawn(NodeBundle{
style:Style{
display:Display::Grid,
padding:UiRect::all(Val::Px(2.0)),
..default()
},
background_color: liveness_color(true),
..default()
})
.with_children(|builder|{
if position.is_active_automaton()
{
builder.spawn(
(
ButtonBundle{
background_color: liveness_color(live),
..default()
},
position
)
);
}else{
builder.spawn(
(
NodeBundle{
background_color: liveness_color(live),
..default()
},
position
)
);
}
});
}
Wir geben eine visuelle Zelle mit der gleichnamigen Funktion cell aus. Wir lassen uns auf einige CSS-Grid-Schikanen ein, um unsere Zelle mit einem 2px-Rahmen zu umgeben. is_active_automaton antwortet auf true, wenn und nur wenn die Zeile der neuesten Generation entspricht, so dass wir sie verwenden, um zu entscheiden, ob wir eine klickbare ButtonBundle -Komponente oder eine inaktive NodeBundle -Komponente anhängen. Die Farbe der Zelle legen wir mit liveness_color fest, so dass die Zellen schwarz für "ein" und weiß für "aus" sind.
Wenn Sie genau hinsehen, werden Sie sehen, dass spawn 2 Tupel empfängt - das UI-Bundle und unser CellPosition. Die resultierende Entität wird beide Komponenten enthalten. Dies wird wichtig sein, wenn wir das System evolve ausführen.
Anleitungs-Banner
Der Aufbau des Anleitungsbanners ist sehr ähnlich, enthält aber ein paar neue Teile:
fn build_instruction_banner(builder:&mutChildBuilder)
{
builder
.spawn(
(
NodeBundle{
style:Style{
display:Display::Flex,
position_type:PositionType::Absolute,
height:Val::Px(50.0),
width:Val::Percent(100.0),
padding:UiRect::all(Val::Px(8.0)),
top:Val::Px(50.0),
justify_content:JustifyContent::Center,
..default()
},
background_color:BackgroundColor(
Color::rgba(0.0,0.0,0.0,0.8)
),
..default()
},
Instructions
)
)
.with_children(|builder|{
builder.spawn(
TextBundle::from_section(
"[space] to resume/pause,[right shift] to
show FPS, or type a new rule",
TextStyle{
font_size:28.0,
color: LABEL_COLOR,
..default()
}
)
.with_style(Style{
align_self:AlignSelf::Center,
..default()
})
);
});
}
Da wir ein Overlay erstellen, verwenden wir eine absolute Positionierung. Wir machen den Hintergrund größtenteils undurchsichtig, damit der Kontrast ausreicht, um die Beschriftung zu lesen. Wir fügen eine benutzerdefinierte Instructions Komponente an das Overlay an. Dabei handelt es sich um eine zustandslose Markierungskomponente, die das Overlay für einen einfachen späteren Zugriff kennzeichnet.
#[derive(Component)]
structInstructions;
Innerhalb des Overlays platzieren wir ein TextBundle, das den gewünschten Text enthält und gestaltet. Ein TextBundle besteht aus mehreren Abschnitten, von denen jeder einen anderen Text enthält. Dies ermöglicht eine einfache stückweise Ersetzung - Ihr Etikett kann statische und dynamische Teile haben, und Sie tauschen die dynamischen Teile einfach aus, wenn sie sich ändern. In diesem Etikett muss sich jedoch nichts ändern, daher verwenden wir nur einen einzigen Abschnitt.
Es gibt zwar mehrere Zentrierungsstrategien, die eigentlich funktionieren sollten, aber die CSS-Implementierung weist Unzulänglichkeiten auf, und ich habe nur eine Strategie gefunden, die in allen Fällen zuverlässig funktioniert:
- Setzen Sie in der übergeordneten Entität
StyledisplayaufDisplay::Flex. - Setzen Sie in der übergeordneten Entität
Stylejustify_contentaufJustifyContent::Center. - Setzen Sie in der
Styleder untergeordneten EntitätTextBundlealign_selfaufAlignSelf::Center.
Sparen Sie sich etwas Zeit und befolgen Sie diese Schritte, wenn Sie Text in Bevy zentrieren möchten!
Nächste-Regel-Banner
Das Banner für die nächste Regel zeigt die gepufferten Benutzereingaben an, die zur Aufnahme der nächsten Regel beitragen. Es ist build_instruction_banner so ähnlich, dass wir den größten Teil des Codes ignorieren und uns nur auf den Unterschied konzentrieren können:
fn build_next_rule_banner(builder:&mutChildBuilder)
{
builder
.spawn(
(
NodeBundle{
style:Style{
display:Display::None,
position_type:PositionType::Absolute,
height:Val::Px(50.0),
width:Val::Px(300.0),
padding:UiRect::all(Val::Px(8.0)),
bottom:Val::Px(50.0),
left:Val::Px(50.0),
..default()
},
background_color:BackgroundColor(
Color::rgba(0.0,0.0,0.0,0.8)
),
..default()
},
NextRule
)
)
.with_children(|builder|{
builder
.spawn(
(
TextBundle::from_sections([
TextSection::new(
"Next up: ",
TextStyle{
font_size:32.0,
color: LABEL_COLOR,
..default()
},
),
TextSection::from_style(TextStyle{
font_size:32.0,
color: LABEL_COLOR,
..default()
})
]),
NextRuleLabel
)
);
});
}
Wir fügen eine benutzerdefinierte NextRule Komponente anstelle einer Instructions Komponente hinzu, aber sie dient dem gleichen Zweck - dieser Entität eine systemische Identität zu geben, die innerhalb der Anwendung eindeutig adressierbar ist.
#[derive(Component)]
structNextRuleLabel;
Dieses Mal übergeben wir ein Array von TextSectionan TextBundle::from_sections. Den ersten Abschnitt behandeln wir als statischen Text, den zweiten als dynamischen. Insbesondere aktualisieren wir den zweiten Abschnitt, um die aktuell gepufferte nächste Regel anzuzeigen. Wir fügen eine weitere benutzerdefinierte Markierungskomponente, NextRuleLabel, an die TextBundle an.
#[derive(Component)]
structNextRuleLabel;
FPS-Banner
Das FPS-Banner ist bis auf die Position, den spezifischen Text und die Markierungskomponenten identisch mit dem Banner für die nächste Regel. Wir ersetzen "FPS: " für "Next up:", die Fps Komponente für die NextRule Komponente, und die FpsLabel Komponente für die NextRuleLabel Komponente.
#[derive(Component)]
structFps;
#[derive(Component)]
structFpsLabel;
fn build_fps_banner(builder:&mutChildBuilder)
{
builder
.spawn(
(
NodeBundle{
style:Style{
display:Display::None,
position_type:PositionType::Absolute,
height:Val::Px(50.0),
width:Val::Px(200.0),
padding:UiRect::all(Val::Px(8.0)),
bottom:Val::Px(50.0),
right:Val::Px(50.0),
..default()
},
background_color:BackgroundColor(
Color::rgba(0.0,0.0,0.0,0.8)
),
..default()
},
Fps
)
)
.with_children(|builder|{
builder
.spawn(
(
TextBundle::from_sections([
TextSection::new(
"FPS: ",
TextStyle{
font_size:32.0,
color: LABEL_COLOR,
..default()
},
),
TextSection::from_style(TextStyle{
font_size:32.0,
color: LABEL_COLOR,
..default()
})
]),
FpsLabel
)
);
});
}
Schön, wir sind mit dem Aufbau der Benutzeroberfläche fertig. Im nächsten und letzten Teil dieser dreiteiligen Blogserie werden wir Dynamik hinzufügen - Evolution und Benutzerinteraktivität.
Verfasst von
Todd Smith
Rust Solution Architect at Xebia Functional. Co-maintainer of the Avail programming language.
Unsere Ideen
Weitere Blogs
Contact




