vibeless
This commit is contained in:
Generated
+1
-1
@@ -4986,7 +4986,7 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tt_net_test"
|
name = "tt_net_test_vibeless"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bevy",
|
"bevy",
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tt_net_test"
|
name = "tt_net_test_vibeless"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
|
|||||||
+1
-7
@@ -1,10 +1,4 @@
|
|||||||
//! Bevy 0.19 demo: a server/client session launcher.
|
|
||||||
//!
|
|
||||||
//! The launcher UI lives in its own module/plugin: [`net_ui::NetUiPlugin`].
|
|
||||||
//! Networking logic lives in [`networking::NetworkingPlugin`].
|
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
use net_ui::NetUiPlugin;
|
use net_ui::NetUiPlugin;
|
||||||
use networking::NetworkingPlugin;
|
use networking::NetworkingPlugin;
|
||||||
|
|
||||||
@@ -15,4 +9,4 @@ fn main() {
|
|||||||
App::new()
|
App::new()
|
||||||
.add_plugins((DefaultPlugins, NetUiPlugin, NetworkingPlugin))
|
.add_plugins((DefaultPlugins, NetUiPlugin, NetworkingPlugin))
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|||||||
-456
@@ -1,457 +1 @@
|
|||||||
//! Bevy 0.19 demo: a server/client session launcher UI, as a plugin.
|
|
||||||
//!
|
|
||||||
//! This is *UI only*. Buttons emit [`NetworkAction`](crate::networking::NetworkAction)
|
|
||||||
//! intents to the networking layer; they never touch aeronet themselves. This
|
|
||||||
//! module renders whatever [`NetUi`] says, and reacts to that resource changing.
|
|
||||||
//!
|
|
||||||
//! UI is built with Bevy 0.19's new scene notation (`bsn!`) composing classic
|
|
||||||
//! `bevy_ui` components (`Node`, `Button`, `Text`, `EditableText`).
|
|
||||||
//!
|
|
||||||
//! Add [`NetUiPlugin`] to any app to get the launcher: it exposes the [`NetUi`]
|
|
||||||
//! resource as the UI's source of truth, so external systems (e.g. aeronet
|
|
||||||
//! observers) can drive the UI by writing status/log/screen into it.
|
|
||||||
|
|
||||||
use bevy::{
|
|
||||||
input_focus::InputFocus,
|
|
||||||
prelude::*,
|
|
||||||
text::EditableText,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::networking::NetworkAction;
|
|
||||||
|
|
||||||
/// Plugin that provides the session-launcher UI.
|
|
||||||
///
|
|
||||||
/// Registers the [`NetUi`] resource, spawns a 2D camera (required for UI), and
|
|
||||||
/// adds the systems that rebuild the screen, react to buttons, and repaint the
|
|
||||||
/// status/log text.
|
|
||||||
pub struct NetUiPlugin;
|
|
||||||
|
|
||||||
impl Plugin for NetUiPlugin {
|
|
||||||
fn build(&self, app: &mut App) {
|
|
||||||
app.configure_sets(Update, NetUiSystems)
|
|
||||||
.init_resource::<InputFocus>()
|
|
||||||
.init_resource::<NetUi>()
|
|
||||||
.add_systems(Startup, spawn_camera)
|
|
||||||
.add_systems(
|
|
||||||
Update,
|
|
||||||
(
|
|
||||||
// Rebuild the whole UI whenever the screen/state changes.
|
|
||||||
// The `bsn!` scene is declarative, so the cheapest "re-render"
|
|
||||||
// is to despawn the previous scene root and spawn the current one.
|
|
||||||
rebuild_net_ui.run_if(resource_changed::<NetUi>),
|
|
||||||
// React to button presses and form fields, emitting intents.
|
|
||||||
handle_buttons.in_set(NetUiSystems),
|
|
||||||
// Repaint the status + log text from the resource.
|
|
||||||
paint_log.run_if(resource_changed::<NetUi>),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Systems that read the UI widgets and emit [`NetworkAction`] intents.
|
|
||||||
/// The networking layer schedules after this set, so it sees intents the
|
|
||||||
/// same frame the button was pressed.
|
|
||||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
|
||||||
pub struct NetUiSystems;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// State
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Source of truth for the launcher UI.
|
|
||||||
///
|
|
||||||
/// This is a *view-model*: it decides what the UI shows. External systems (the
|
|
||||||
/// future aeronet observers) write status/log into it; they do not read network
|
|
||||||
/// truth *from* it. The authoritative connection state lives in aeronet session
|
|
||||||
/// entities.
|
|
||||||
#[derive(Resource)]
|
|
||||||
pub struct NetUi {
|
|
||||||
/// Which screen of the launcher is showing. Changing it rebuilds the UI.
|
|
||||||
pub screen: Screen,
|
|
||||||
/// Short status line shown at the bottom of every screen.
|
|
||||||
pub status: String,
|
|
||||||
/// Chronological log of (would-be) network actions, shown at the bottom.
|
|
||||||
pub log: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for NetUi {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
screen: Screen::Menu,
|
|
||||||
status: "Idle — no networking wired yet (UI only)".into(),
|
|
||||||
log: Vec::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl NetUi {
|
|
||||||
/// Append a line to the log panel.
|
|
||||||
pub fn push_log(&mut self, line: impl Into<String>) {
|
|
||||||
self.log.push(line.into());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Which screen of the launcher is showing. Changing it rebuilds the UI.
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum Screen {
|
|
||||||
Menu,
|
|
||||||
Host,
|
|
||||||
Client,
|
|
||||||
Session,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Marker components (also hooked into by the BSN scene builders)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Marks the scene root so it can be despawned on rebuild.
|
|
||||||
#[derive(Component, Default, Clone, Copy)]
|
|
||||||
struct NetUiRoot;
|
|
||||||
|
|
||||||
#[derive(Component, Default, Clone, Copy)]
|
|
||||||
struct HostButton;
|
|
||||||
#[derive(Component, Default, Clone, Copy)]
|
|
||||||
struct ClientButton;
|
|
||||||
#[derive(Component, Default, Clone, Copy)]
|
|
||||||
struct BackButton;
|
|
||||||
#[derive(Component, Default, Clone, Copy)]
|
|
||||||
struct StartHostButton;
|
|
||||||
#[derive(Component, Default, Clone, Copy)]
|
|
||||||
struct ConnectButton;
|
|
||||||
#[derive(Component, Default, Clone, Copy)]
|
|
||||||
struct StopButton;
|
|
||||||
|
|
||||||
#[derive(Component, Default, Clone, Copy)]
|
|
||||||
struct ConnectUrlField;
|
|
||||||
#[derive(Component, Default, Clone, Copy)]
|
|
||||||
struct HostPortField;
|
|
||||||
|
|
||||||
#[derive(Component, Default, Clone, Copy)]
|
|
||||||
struct StatusText;
|
|
||||||
#[derive(Component, Default, Clone, Copy)]
|
|
||||||
struct LogText;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Setup
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn spawn_camera(mut commands: Commands) {
|
|
||||||
commands.spawn(Camera2d);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Rebuild
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn rebuild_net_ui(
|
|
||||||
mut commands: Commands,
|
|
||||||
ui: Res<NetUi>,
|
|
||||||
roots: Query<Entity, With<NetUiRoot>>,
|
|
||||||
) {
|
|
||||||
for root in &roots {
|
|
||||||
commands.entity(root).despawn();
|
|
||||||
}
|
|
||||||
match ui.screen {
|
|
||||||
Screen::Menu => {
|
|
||||||
commands.spawn_scene(menu_screen()).insert(NetUiRoot);
|
|
||||||
}
|
|
||||||
Screen::Host => {
|
|
||||||
commands.spawn_scene(host_screen()).insert(NetUiRoot);
|
|
||||||
}
|
|
||||||
Screen::Client => {
|
|
||||||
commands.spawn_scene(client_screen()).insert(NetUiRoot);
|
|
||||||
}
|
|
||||||
Screen::Session => {
|
|
||||||
commands.spawn_scene(session_screen()).insert(NetUiRoot);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Buttons + fields
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn handle_buttons(
|
|
||||||
mut ui: ResMut<NetUi>,
|
|
||||||
mut actions: MessageWriter<NetworkAction>,
|
|
||||||
url_field: Query<&EditableText, With<ConnectUrlField>>,
|
|
||||||
port_field: Query<&EditableText, With<HostPortField>>,
|
|
||||||
q_host: Query<&Interaction, (With<HostButton>, Changed<Interaction>)>,
|
|
||||||
q_client: Query<&Interaction, (With<ClientButton>, Changed<Interaction>)>,
|
|
||||||
q_back: Query<&Interaction, (With<BackButton>, Changed<Interaction>)>,
|
|
||||||
q_start: Query<&Interaction, (With<StartHostButton>, Changed<Interaction>)>,
|
|
||||||
q_connect: Query<&Interaction, (With<ConnectButton>, Changed<Interaction>)>,
|
|
||||||
q_stop: Query<&Interaction, (With<StopButton>, Changed<Interaction>)>,
|
|
||||||
) {
|
|
||||||
use Interaction::Pressed;
|
|
||||||
|
|
||||||
// -------- Screen navigation (pure UI, no networking) ------------------
|
|
||||||
if q_host.iter().any(|i| *i == Pressed) {
|
|
||||||
ui.screen = Screen::Host;
|
|
||||||
ui.status = "Host setup — pick a listen port.".into();
|
|
||||||
}
|
|
||||||
if q_client.iter().any(|i| *i == Pressed) {
|
|
||||||
ui.screen = Screen::Client;
|
|
||||||
ui.status = "Client setup — enter a server URL.".into();
|
|
||||||
}
|
|
||||||
if q_back.iter().any(|i| *i == Pressed) {
|
|
||||||
ui.screen = Screen::Menu;
|
|
||||||
ui.status = "Idle — select an action.".into();
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- Host: start server ------------------------------------------
|
|
||||||
// The next three only *request* the action; the networking layer owns the
|
|
||||||
// result and updates `NetUi`. None of this spawns sockets or reacts to them.
|
|
||||||
if q_start.iter().any(|i| *i == Pressed) {
|
|
||||||
let port: u16 = port_field
|
|
||||||
.single()
|
|
||||||
.map(|e| e.value().to_string())
|
|
||||||
.ok()
|
|
||||||
.and_then(|p| p.trim().parse().ok())
|
|
||||||
.unwrap_or(25570);
|
|
||||||
actions.write(NetworkAction::StartHost { port });
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- Client: connect ---------------------------------------------
|
|
||||||
if q_connect.iter().any(|i| *i == Pressed) {
|
|
||||||
let url = url_field
|
|
||||||
.single()
|
|
||||||
.map(|e| e.value().to_string())
|
|
||||||
.unwrap_or_default();
|
|
||||||
let url = if url.trim().is_empty() {
|
|
||||||
"ws://127.0.0.1:25570".to_string()
|
|
||||||
} else {
|
|
||||||
url.trim().to_string()
|
|
||||||
};
|
|
||||||
actions.write(NetworkAction::Connect { url });
|
|
||||||
}
|
|
||||||
|
|
||||||
// -------- Session: stop -----------------------------------------------
|
|
||||||
if q_stop.iter().any(|i| *i == Pressed) {
|
|
||||||
actions.write(NetworkAction::Disconnect);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Paint dynamic text
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn paint_log(
|
|
||||||
ui: Res<NetUi>,
|
|
||||||
mut texts: Query<(Entity, &mut Text, Has<StatusText>, Has<LogText>)>,
|
|
||||||
) {
|
|
||||||
for (_e, mut text, is_status, is_log) in &mut texts {
|
|
||||||
if is_status {
|
|
||||||
text.0 = ui.status.clone();
|
|
||||||
} else if is_log {
|
|
||||||
text.0 = if ui.log.is_empty() {
|
|
||||||
"(nothing yet)".to_string()
|
|
||||||
} else {
|
|
||||||
ui.log.join("\n")
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Reusable BSN leaf scenes
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn title(text: &str) -> impl Scene {
|
|
||||||
bsn! {
|
|
||||||
Text(text)
|
|
||||||
TextFont { font_size: px(30.) }
|
|
||||||
TextColor(Color::srgb(0.93, 0.94, 0.97))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn subtitle(text: &str) -> impl Scene {
|
|
||||||
bsn! {
|
|
||||||
Text(text)
|
|
||||||
TextFont { font_size: px(15.) }
|
|
||||||
TextColor(Color::srgb(0.60, 0.64, 0.70))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn field_label(text: &str) -> impl Scene {
|
|
||||||
bsn! {
|
|
||||||
Text(text)
|
|
||||||
TextFont { font_size: px(13.) }
|
|
||||||
TextColor(Color::srgb(0.55, 0.60, 0.66))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn net_button(label: &str) -> impl Scene {
|
|
||||||
bsn! {
|
|
||||||
Button
|
|
||||||
Node {
|
|
||||||
width: px(300),
|
|
||||||
height: px(44),
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
border_radius: BorderRadius::MAX,
|
|
||||||
}
|
|
||||||
BackgroundColor(Color::srgb(0.16, 0.22, 0.32))
|
|
||||||
Children [
|
|
||||||
Text(label)
|
|
||||||
TextFont { font_size: px(16.) }
|
|
||||||
TextColor(Color::srgb(0.90, 0.92, 0.95)),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn ghost_button(label: &str) -> impl Scene {
|
|
||||||
bsn! {
|
|
||||||
Button
|
|
||||||
Node {
|
|
||||||
width: px(300),
|
|
||||||
height: px(40),
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
border_radius: BorderRadius::MAX,
|
|
||||||
border: px(1),
|
|
||||||
}
|
|
||||||
BackgroundColor(Color::srgb(0.10, 0.12, 0.15))
|
|
||||||
BorderColor::all(Color::srgb(0.30, 0.35, 0.40))
|
|
||||||
Children [
|
|
||||||
Text(label)
|
|
||||||
TextFont { font_size: px(14.) }
|
|
||||||
TextColor(Color::srgb(0.70, 0.73, 0.78)),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn field_input() -> impl Scene {
|
|
||||||
bsn! {
|
|
||||||
EditableText { visible_width: {Some(20.)} }
|
|
||||||
Node {
|
|
||||||
width: px(300),
|
|
||||||
border: px(1),
|
|
||||||
border_radius: px(8),
|
|
||||||
padding: px(10),
|
|
||||||
}
|
|
||||||
BackgroundColor(Color::srgb(0.08, 0.10, 0.12))
|
|
||||||
BorderColor::all(Color::srgb(0.28, 0.33, 0.40))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn status_text(initial: &str) -> impl Scene {
|
|
||||||
bsn! {
|
|
||||||
Text(initial)
|
|
||||||
TextFont { font_size: px(13.) }
|
|
||||||
TextColor(Color::srgb(0.45, 0.55, 0.72))
|
|
||||||
StatusText
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn log_list() -> impl Scene {
|
|
||||||
bsn! {
|
|
||||||
Node {
|
|
||||||
margin: UiRect::top(px(20)),
|
|
||||||
width: px(420),
|
|
||||||
flex_direction: FlexDirection::Column,
|
|
||||||
align_items: AlignItems::Stretch,
|
|
||||||
padding: px(10),
|
|
||||||
border: px(1),
|
|
||||||
border_radius: px(8),
|
|
||||||
}
|
|
||||||
BackgroundColor(Color::srgb(0.06, 0.08, 0.10))
|
|
||||||
BorderColor::all(Color::srgb(0.20, 0.24, 0.30))
|
|
||||||
Children [
|
|
||||||
(
|
|
||||||
Text("Log")
|
|
||||||
TextFont { font_size: px(12.) }
|
|
||||||
TextColor(Color::srgb(0.50, 0.55, 0.62))
|
|
||||||
),
|
|
||||||
(
|
|
||||||
Text("(nothing yet)")
|
|
||||||
LogText
|
|
||||||
TextFont { font_size: px(12.) }
|
|
||||||
TextColor(Color::srgb(0.55, 0.60, 0.66))
|
|
||||||
TextLayout { linebreak: LineBreak::WordOrCharacter }
|
|
||||||
),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn spacer(height: f32) -> impl Scene {
|
|
||||||
bsn! {
|
|
||||||
Node { width: px(1), height: px(height) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A centered column that every screen shares.
|
|
||||||
fn screen_body(children: impl SceneList) -> impl Scene {
|
|
||||||
bsn! {
|
|
||||||
Node {
|
|
||||||
width: percent(100),
|
|
||||||
height: percent(100),
|
|
||||||
flex_direction: FlexDirection::Column,
|
|
||||||
justify_content: JustifyContent::Center,
|
|
||||||
align_items: AlignItems::Center,
|
|
||||||
row_gap: px(14),
|
|
||||||
}
|
|
||||||
BackgroundColor(Color::srgb(0.05, 0.06, 0.08))
|
|
||||||
Children [ {children} ]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Per-screen scenes
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
fn menu_screen() -> impl Scene {
|
|
||||||
screen_body(bsn_list![
|
|
||||||
title("Aronet session launcher"),
|
|
||||||
subtitle("Host a dedicated server, or connect as a client."),
|
|
||||||
spacer(8.0),
|
|
||||||
(net_button("Host a server") HostButton),
|
|
||||||
(net_button("Connect to a client") ClientButton),
|
|
||||||
spacer(6.0),
|
|
||||||
status_text("Idle"),
|
|
||||||
log_list(),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn host_screen() -> impl Scene {
|
|
||||||
screen_body(bsn_list![
|
|
||||||
title("Host a server"),
|
|
||||||
subtitle("WebSocket server; pick a listen port."),
|
|
||||||
spacer(8.0),
|
|
||||||
field_label("Listen port"),
|
|
||||||
(field_input() HostPortField),
|
|
||||||
spacer(10.0),
|
|
||||||
(net_button("Start server") StartHostButton),
|
|
||||||
(ghost_button("Back") BackButton),
|
|
||||||
status_text("Host setup"),
|
|
||||||
log_list(),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn client_screen() -> impl Scene {
|
|
||||||
screen_body(bsn_list![
|
|
||||||
title("Connect to a server"),
|
|
||||||
subtitle("WebSocket URL the server is listening on."),
|
|
||||||
spacer(8.0),
|
|
||||||
field_label("Server URL (e.g. ws://127.0.0.1:25570)"),
|
|
||||||
(field_input() ConnectUrlField),
|
|
||||||
spacer(10.0),
|
|
||||||
(net_button("Connect") ConnectButton),
|
|
||||||
(ghost_button("Back") BackButton),
|
|
||||||
status_text("Client setup"),
|
|
||||||
log_list(),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
fn session_screen() -> impl Scene {
|
|
||||||
screen_body(bsn_list![
|
|
||||||
title("In session"),
|
|
||||||
subtitle("UI shell only — the aeronet wiring goes here."),
|
|
||||||
spacer(10.0),
|
|
||||||
(net_button("Disconnect / Stop") StopButton),
|
|
||||||
(ghost_button("Back to menu") BackButton),
|
|
||||||
status_text("In session"),
|
|
||||||
log_list(),
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user