preliminary main menu
This commit is contained in:
Generated
+424
-230
File diff suppressed because it is too large
Load Diff
+2
-1
@@ -4,7 +4,8 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bevy = "0.19.0"
|
bevy = "0.19"
|
||||||
|
derive_more = { version = "2.1.1", features = ["deref", "deref_mut"] }
|
||||||
|
|
||||||
[profile.dev]
|
[profile.dev]
|
||||||
opt-level = 1
|
opt-level = 1
|
||||||
|
|||||||
+40
-4
@@ -1,12 +1,48 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::{camera::ScalingMode, color::palettes::css::GREEN, prelude::*};
|
||||||
use net_ui::NetUiPlugin;
|
use main_menu::MainMenuPlugin;
|
||||||
use networking::NetworkingPlugin;
|
use networking::NetworkingPlugin;
|
||||||
|
|
||||||
mod net_ui;
|
mod main_menu;
|
||||||
mod networking;
|
mod networking;
|
||||||
|
|
||||||
|
/// We target this 16:9 resolution to make things easier.
|
||||||
|
pub const WINDOW_TARGET_WIDTH_U32: u32 = 3840;
|
||||||
|
pub const WINDOW_TARGET_HEIGHT_U32: u32 = 2160;
|
||||||
|
pub const WINDOW_TARGET_WIDTH: f32 = WINDOW_TARGET_WIDTH_U32 as f32;
|
||||||
|
pub const WINDOW_TARGET_HEIGHT: f32 = WINDOW_TARGET_HEIGHT_U32 as f32;
|
||||||
|
|
||||||
|
/// The main overarching state of the app.
|
||||||
|
#[derive(States, Debug, Clone, Copy, Default, Eq, PartialEq, Hash)]
|
||||||
|
pub enum AppState {
|
||||||
|
#[default]
|
||||||
|
Menu,
|
||||||
|
Lobby,
|
||||||
|
Game,
|
||||||
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
App::new()
|
App::new()
|
||||||
.add_plugins((DefaultPlugins, NetUiPlugin, NetworkingPlugin))
|
.add_plugins((DefaultPlugins, MainMenuPlugin, NetworkingPlugin))
|
||||||
|
.add_systems(Startup, setup)
|
||||||
|
.add_systems(Update, debug_border)
|
||||||
|
.insert_resource(ClearColor(Color::srgb(0.07, 0.33, 0.45)))
|
||||||
|
.init_state::<AppState>()
|
||||||
.run();
|
.run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn setup(mut commands: Commands) {
|
||||||
|
let mut projection = OrthographicProjection::default_2d();
|
||||||
|
projection.scaling_mode = ScalingMode::AutoMin {
|
||||||
|
min_width: WINDOW_TARGET_WIDTH,
|
||||||
|
min_height: WINDOW_TARGET_HEIGHT,
|
||||||
|
};
|
||||||
|
commands.spawn((Camera2d, Projection::Orthographic(projection)));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn debug_border(mut gizmos: Gizmos) {
|
||||||
|
gizmos.rect_2d(
|
||||||
|
Isometry2d::IDENTITY,
|
||||||
|
Vec2::new(WINDOW_TARGET_WIDTH, WINDOW_TARGET_HEIGHT),
|
||||||
|
GREEN,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
use crate::{AppState, WINDOW_TARGET_HEIGHT, WINDOW_TARGET_WIDTH};
|
||||||
|
use bevy::ui_widgets::Button;
|
||||||
|
use bevy::{prelude::*, ui_widgets::Activate};
|
||||||
|
use derive_more::{Deref, DerefMut};
|
||||||
|
|
||||||
|
pub struct MainMenuPlugin;
|
||||||
|
|
||||||
|
impl Plugin for MainMenuPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.add_systems(OnEnter(AppState::Menu), setup_menu)
|
||||||
|
.add_systems(OnExit(AppState::Menu), cleanup_menu)
|
||||||
|
.add_systems(Update, (menu_redraw).run_if(in_state(AppState::Menu)))
|
||||||
|
.add_observer(button_activate_observer)
|
||||||
|
.add_systems(Update, (scale_ui, style_ui_elements));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const N_LAYERS: usize = 5;
|
||||||
|
|
||||||
|
/// A UI object that is selected.
|
||||||
|
#[derive(Component, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Debug)]
|
||||||
|
struct Selected;
|
||||||
|
|
||||||
|
/// Defines colors that a UI object should turn when interactions occur.
|
||||||
|
#[derive(Component, Clone, PartialEq, Default, Debug)]
|
||||||
|
struct InteractionColors {
|
||||||
|
default: Color,
|
||||||
|
hovered: Color,
|
||||||
|
pressed: Color,
|
||||||
|
selected: Color,
|
||||||
|
selected_hovered: Color,
|
||||||
|
selected_pressed: Color,
|
||||||
|
}
|
||||||
|
impl InteractionColors {
|
||||||
|
pub const DEFAULT: InteractionColors = InteractionColors {
|
||||||
|
default: Color::srgb(0.25, 0.63, 0.84),
|
||||||
|
hovered: Color::srgb(0.31, 0.77, 0.93),
|
||||||
|
pressed: Color::srgb(0.16, 0.55, 0.89),
|
||||||
|
selected: Color::srgb(0.93, 0.66, 0.06),
|
||||||
|
selected_hovered: Color::srgb(1., 0.74, 0.16),
|
||||||
|
selected_pressed: Color::srgb(0.86, 0.56, 0.),
|
||||||
|
};
|
||||||
|
pub const GOOD: InteractionColors = InteractionColors {
|
||||||
|
default: Color::srgb(0.25, 0.75, 0.25),
|
||||||
|
hovered: Color::srgb(0.35, 0.85, 0.35),
|
||||||
|
pressed: Color::srgb(0.15, 0.65, 0.15),
|
||||||
|
selected: Color::srgb(0.35, 0.8, 0.35),
|
||||||
|
selected_hovered: Color::srgb(0.45, 0.9, 0.45),
|
||||||
|
selected_pressed: Color::srgb(0.25, 0.7, 0.25),
|
||||||
|
};
|
||||||
|
pub const DANGER: InteractionColors = InteractionColors {
|
||||||
|
default: Color::srgb(0.75, 0.25, 0.25),
|
||||||
|
hovered: Color::srgb(0.85, 0.35, 0.35),
|
||||||
|
pressed: Color::srgb(0.65, 0.15, 0.15),
|
||||||
|
selected: Color::srgb(0.8, 0.3, 0.3),
|
||||||
|
selected_hovered: Color::srgb(0.9, 0.4, 0.4),
|
||||||
|
selected_pressed: Color::srgb(0.7, 0.2, 0.2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The root of the menu
|
||||||
|
#[derive(Component, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Debug)]
|
||||||
|
struct MenuRoot;
|
||||||
|
|
||||||
|
/// The layer that the page is on.
|
||||||
|
#[derive(
|
||||||
|
Component, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Debug, Deref, DerefMut,
|
||||||
|
)]
|
||||||
|
struct Layer(usize);
|
||||||
|
|
||||||
|
/// A component that indicates the layer that this button points to.
|
||||||
|
#[derive(Component, Copy, Clone, PartialEq, Eq, Debug, Default)]
|
||||||
|
struct NextLayer(MenuLayer);
|
||||||
|
|
||||||
|
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Default)]
|
||||||
|
pub enum MenuLayer {
|
||||||
|
#[default]
|
||||||
|
Root,
|
||||||
|
Play,
|
||||||
|
LoadSave,
|
||||||
|
Browse,
|
||||||
|
DirectConnect,
|
||||||
|
Settings,
|
||||||
|
Controls,
|
||||||
|
Graphics,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MenuLayer {
|
||||||
|
fn render(&self, layer: usize, menu_root: Entity) -> Box<dyn Scene> {
|
||||||
|
match self {
|
||||||
|
MenuLayer::Root => Box::new(bsn! {
|
||||||
|
menu_layer(layer)
|
||||||
|
ChildOf(menu_root)
|
||||||
|
Children [
|
||||||
|
menu_button("Play", Some(MenuLayer::Play)),
|
||||||
|
menu_button("Settings", Some(MenuLayer::Settings)),
|
||||||
|
(
|
||||||
|
menu_button("Quit", None)
|
||||||
|
on(|_event: On<Activate>, mut exit: MessageWriter<AppExit>| { exit.write(AppExit::Success); } )
|
||||||
|
InteractionColors::DANGER
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
MenuLayer::Play => Box::new(bsn! {
|
||||||
|
menu_layer(layer)
|
||||||
|
ChildOf(menu_root)
|
||||||
|
Children [
|
||||||
|
menu_button("Load Save", Some(MenuLayer::LoadSave)),
|
||||||
|
menu_button("Browse Games", Some(MenuLayer::Browse)),
|
||||||
|
menu_button("Direct Connect", Some(MenuLayer::DirectConnect)),
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
MenuLayer::LoadSave => Box::new(bsn! {
|
||||||
|
menu_layer(layer)
|
||||||
|
ChildOf(menu_root)
|
||||||
|
Children [
|
||||||
|
menu_button("Save 1", None),
|
||||||
|
menu_button("Save 2", None),
|
||||||
|
(
|
||||||
|
menu_button("New Game", None)
|
||||||
|
InteractionColors::GOOD
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
MenuLayer::Browse => Box::new(bsn! {
|
||||||
|
full_menu_layer(layer)
|
||||||
|
ChildOf(menu_root)
|
||||||
|
Children [
|
||||||
|
menu_button("Game 1", None),
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
MenuLayer::DirectConnect => Box::new(bsn! {
|
||||||
|
full_menu_layer(layer)
|
||||||
|
ChildOf(menu_root)
|
||||||
|
Children [
|
||||||
|
// TODO: have IP addr text box here
|
||||||
|
Text("Enter IP")
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
MenuLayer::Settings => Box::new(bsn! {
|
||||||
|
menu_layer(layer)
|
||||||
|
ChildOf(menu_root)
|
||||||
|
Children [
|
||||||
|
menu_button("Controls", Some(MenuLayer::Controls)),
|
||||||
|
menu_button("Graphics", Some(MenuLayer::Graphics)),
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
MenuLayer::Controls => Box::new(bsn! {
|
||||||
|
full_menu_layer(layer)
|
||||||
|
ChildOf(menu_root)
|
||||||
|
Children [
|
||||||
|
Text("Controls..")
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
MenuLayer::Graphics => Box::new(bsn! {
|
||||||
|
full_menu_layer(layer)
|
||||||
|
ChildOf(menu_root)
|
||||||
|
Children [
|
||||||
|
Text("Graphics..")
|
||||||
|
]
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The state of the menu last frame.
|
||||||
|
#[derive(Resource, Debug, Clone, PartialEq, Eq, Hash, Deref, DerefMut)]
|
||||||
|
struct LastMenu(Vec<MenuLayer>);
|
||||||
|
/// The current state of the menu.
|
||||||
|
#[derive(Resource, Debug, Clone, PartialEq, Eq, Hash, Deref, DerefMut)]
|
||||||
|
struct CurrentMenu(Vec<MenuLayer>);
|
||||||
|
|
||||||
|
fn setup_menu(mut commands: Commands) {
|
||||||
|
commands.insert_resource(CurrentMenu(vec![MenuLayer::Root]));
|
||||||
|
commands.insert_resource(LastMenu(vec![]));
|
||||||
|
|
||||||
|
commands.spawn_scene(bsn! {
|
||||||
|
#MenuRoot
|
||||||
|
MenuRoot
|
||||||
|
DespawnOnExit::<AppState>(AppState::Menu)
|
||||||
|
Node {
|
||||||
|
width: percent(90),
|
||||||
|
height: percent(70),
|
||||||
|
margin: UiRect::AUTO,
|
||||||
|
align_items: AlignItems::Start,
|
||||||
|
justify_content: JustifyContent::Start,
|
||||||
|
column_gap: px(10),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleanup_menu(mut commands: Commands) {
|
||||||
|
commands.remove_resource::<CurrentMenu>();
|
||||||
|
commands.remove_resource::<LastMenu>();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn menu_redraw(
|
||||||
|
mut commands: Commands,
|
||||||
|
mut last_menu: ResMut<LastMenu>,
|
||||||
|
current_menu: Res<CurrentMenu>,
|
||||||
|
q_layers: Query<(Entity, &Layer), Without<MenuRoot>>,
|
||||||
|
s_menu_root: Single<Entity, (With<MenuRoot>, Without<Layer>)>,
|
||||||
|
) {
|
||||||
|
if current_menu.is_changed() {
|
||||||
|
let last_equal_layer = last_menu
|
||||||
|
.iter()
|
||||||
|
.zip(current_menu.iter())
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(idx, (last, current))| (last == current).then_some(idx as i32))
|
||||||
|
.last()
|
||||||
|
.unwrap_or(-1);
|
||||||
|
|
||||||
|
// delete any layers that have been deleted/changed
|
||||||
|
if last_equal_layer < last_menu.len() as i32 {
|
||||||
|
for (e, layer) in q_layers.iter() {
|
||||||
|
if layer.0 as i32 > last_equal_layer {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// render new/changed layers
|
||||||
|
for (idx, layer) in current_menu
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.skip((last_equal_layer + 1) as usize)
|
||||||
|
{
|
||||||
|
commands.spawn_scene(layer.render(idx, *s_menu_root));
|
||||||
|
}
|
||||||
|
|
||||||
|
// update last menu to match current menu
|
||||||
|
last_menu.0 = current_menu.0.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn menu_layer(layer: usize) -> impl Scene {
|
||||||
|
let name = format!("MenuLayer{}", layer);
|
||||||
|
bsn! {
|
||||||
|
Name(name)
|
||||||
|
Layer(layer)
|
||||||
|
Node {
|
||||||
|
width: percent(100.0 / N_LAYERS as f32),
|
||||||
|
height: percent(100),
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
// align_items: AlignItems::Start,
|
||||||
|
align_items: AlignItems::Baseline,
|
||||||
|
justify_content: JustifyContent::FlexEnd,
|
||||||
|
row_gap: px(100),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn full_menu_layer(layer: usize) -> impl Scene {
|
||||||
|
bsn! {
|
||||||
|
menu_layer(layer)
|
||||||
|
Node {
|
||||||
|
width: Val::Auto,
|
||||||
|
flex_grow: 1.,
|
||||||
|
}
|
||||||
|
BackgroundColor(Color::srgba(0.8, 0.1, 0.1, 0.7))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn menu_button(label: &str, next_layer: Option<MenuLayer>) -> impl Scene {
|
||||||
|
let next_layer_component = next_layer
|
||||||
|
.map(|n| Box::new(bsn! { NextLayer(n) }) as Box<dyn Scene>)
|
||||||
|
.unwrap_or(Box::new(bsn! {}));
|
||||||
|
|
||||||
|
bsn! {
|
||||||
|
Button
|
||||||
|
Node {
|
||||||
|
width: px(600),
|
||||||
|
height: px(130),
|
||||||
|
border: px(6),
|
||||||
|
// border_radius: BorderRadius::MAX,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
margin: UiRect::all(px(4)),
|
||||||
|
}
|
||||||
|
next_layer_component
|
||||||
|
BorderColor::from(Color::BLACK)
|
||||||
|
InteractionColors::DEFAULT
|
||||||
|
Interaction
|
||||||
|
Children [(
|
||||||
|
Text(label)
|
||||||
|
TextFont {
|
||||||
|
// font: FontSourceTemplate::Handle("fonts/FiraSans-Bold.ttf"),
|
||||||
|
font_size: px(66.0),
|
||||||
|
}
|
||||||
|
TextColor(Color::srgb(0.9, 0.9, 0.9))
|
||||||
|
TextShadow
|
||||||
|
)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn button_activate_observer(
|
||||||
|
event: On<Activate>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut menu: ResMut<CurrentMenu>,
|
||||||
|
q_next_layer: Query<(&NextLayer, &ChildOf)>,
|
||||||
|
q_layer: Query<(&Layer, &Children)>,
|
||||||
|
q_selected: Query<(Entity, Has<Selected>)>,
|
||||||
|
) {
|
||||||
|
if let Ok((next_layer, parent)) = q_next_layer.get(event.entity) {
|
||||||
|
let Ok((layer, layer_entries)) = q_layer.get(parent.parent()) else {
|
||||||
|
warn!(
|
||||||
|
"\"Activate\" triggered on entity with `NextLayer`, but it's parent doesn't have `Layer`!"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// remove all the layers after
|
||||||
|
if (layer.0 as usize) < menu.len() {
|
||||||
|
menu.0.drain((layer.0 + 1)..);
|
||||||
|
}
|
||||||
|
// push next layer
|
||||||
|
menu.push(next_layer.0);
|
||||||
|
|
||||||
|
// update the buttons so the correct one is selected
|
||||||
|
for (entity, selected) in q_selected.iter_many(layer_entries) {
|
||||||
|
if entity == event.entity {
|
||||||
|
if !selected {
|
||||||
|
commands.entity(entity).insert(Selected);
|
||||||
|
}
|
||||||
|
} else if selected {
|
||||||
|
commands.entity(entity).remove::<Selected>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Styles the UI elements according to their [`InteractionColors`].
|
||||||
|
fn style_ui_elements(
|
||||||
|
mut q_interaction_set: ParamSet<(
|
||||||
|
Query<
|
||||||
|
(
|
||||||
|
&Interaction,
|
||||||
|
&mut BackgroundColor,
|
||||||
|
&InteractionColors,
|
||||||
|
Has<Selected>,
|
||||||
|
),
|
||||||
|
(Or<(Changed<Interaction>, Added<Selected>)>, With<Button>),
|
||||||
|
>,
|
||||||
|
Query<(&Interaction, &mut BackgroundColor, &InteractionColors)>,
|
||||||
|
)>,
|
||||||
|
mut removed_selected: RemovedComponents<Selected>,
|
||||||
|
) {
|
||||||
|
for (interaction, mut bg, colors, selected) in q_interaction_set.p0().iter_mut() {
|
||||||
|
let new_color = match (selected, interaction) {
|
||||||
|
(true, Interaction::Pressed) => colors.selected_pressed,
|
||||||
|
(true, Interaction::Hovered) => colors.selected_hovered,
|
||||||
|
(true, Interaction::None) => colors.selected,
|
||||||
|
(false, Interaction::Pressed) => colors.pressed,
|
||||||
|
(false, Interaction::Hovered) => colors.hovered,
|
||||||
|
(false, Interaction::None) => colors.default,
|
||||||
|
};
|
||||||
|
bg.0 = new_color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// also do entities that have had `Selected` removed
|
||||||
|
for entity in removed_selected.read() {
|
||||||
|
if let Ok((interaction, mut bg, colors)) = q_interaction_set.p1().get_mut(entity) {
|
||||||
|
let new_color = match interaction {
|
||||||
|
Interaction::Pressed => colors.pressed,
|
||||||
|
Interaction::Hovered => colors.hovered,
|
||||||
|
Interaction::None => colors.default,
|
||||||
|
};
|
||||||
|
bg.0 = new_color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scale_ui(mut ui_scale: ResMut<UiScale>, window: Single<&Window>) {
|
||||||
|
let size = window.size();
|
||||||
|
let scale_factor = f32::min(size.x / WINDOW_TARGET_WIDTH, size.y / WINDOW_TARGET_HEIGHT);
|
||||||
|
ui_scale.0 = scale_factor;
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
+15
-178
@@ -1,188 +1,25 @@
|
|||||||
//! Scaffold for the aeronet networking logic.
|
use bevy::prelude::*;
|
||||||
//!
|
|
||||||
//! **This file is where the actual server/client code goes.** Right now it
|
|
||||||
//! depends only on `bevy::prelude`, so it compiles and runs standalone. The real
|
|
||||||
//! sockets/transport sit behind `// TODO(aeronet)` markers — enabling them means
|
|
||||||
//! adding `aeronet` + an IO layer + `aeronet_transport` to `Cargo.toml` and
|
|
||||||
//! swapping the marked bodies for the real calls (exact API noted below).
|
|
||||||
//!
|
|
||||||
//! ## Division of responsibility
|
|
||||||
//!
|
|
||||||
//! - **UI** (`net_ui`): presentation only. It emits [`NetworkAction`] intents
|
|
||||||
//! and renders whatever [`NetUi`](crate::net_ui::NetUi) says.
|
|
||||||
//! - **Networking** (this module): owns the connection lifecycle. It consumes
|
|
||||||
//! actions, talks to aeronet, and writes status/log/screen back into `NetUi`.
|
|
||||||
//!
|
|
||||||
//! So the UI never touches aeronet, and the networking layer never spawns UI.
|
|
||||||
|
|
||||||
use bevy::{
|
|
||||||
prelude::*,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::net_ui::{NetUi, Screen};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Intent channel: UI -> networking
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// An action the user asked for through the UI. These are the *only* messages
|
|
||||||
/// the UI sends about networking; everything else it just displays.
|
|
||||||
// In bevy 0.19 events are "messages": `#[derive(Message)]` + `MessageReader`/
|
|
||||||
// `MessageWriter` system params (the old `Event`/`EventReader`/`EventWriter`).
|
|
||||||
#[derive(Message, Debug, Clone)]
|
|
||||||
pub enum NetworkAction {
|
|
||||||
/// User pressed "Start server".
|
|
||||||
StartHost { port: u16 },
|
|
||||||
/// User pressed "Connect".
|
|
||||||
Connect { url: String },
|
|
||||||
/// User pressed "Disconnect / Stop".
|
|
||||||
Disconnect,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Plugin
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Owns all networking state and lifecycle.
|
/// Owns all networking state and lifecycle.
|
||||||
pub struct NetworkingPlugin;
|
pub struct NetworkingPlugin;
|
||||||
|
|
||||||
impl Plugin for NetworkingPlugin {
|
impl Plugin for NetworkingPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// Register the UI->networking intent channel.
|
// TODO: impl
|
||||||
app.add_message::<NetworkAction>();
|
let _ = app;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO(aeronet): register the IO + transport plugins once the crates are
|
// TODO(aeronet): transport lanes must be declared up front. Add these (and a
|
||||||
// added. The exact set depends on which IO layer(s) the app supports:
|
// `LaneKind` import) when the crate lands. Boilerplate an order-of-magnitude
|
||||||
// host -> app.add_plugins((aeronet_websocket::server::WebSocketServerPlugin, AeronetTransportPlugin));
|
// easier done with the crate than comments:
|
||||||
// client-> app.add_plugins((aeronet_websocket::client::WebSocketClientPlugin, AeronetTransportPlugin));
|
|
||||||
//
|
//
|
||||||
// Each *_Plugin pulls in `aeronet_io` automatically but NOT transport.
|
// ```ignore
|
||||||
app.init_resource::<Network>()
|
// const LANES: [LaneKind; 2] = [
|
||||||
.add_systems(Update, handle_actions.after(crate::net_ui::NetUiSystems));
|
// LaneKind::ReliableOrdered, // game state / actions
|
||||||
}
|
// LaneKind::UnreliableOrdered, // e.g. position snapshots
|
||||||
}
|
// ];
|
||||||
|
// const SEND_LANE: LaneIndex = LaneIndex::new(0);
|
||||||
// ---------------------------------------------------------------------------
|
// ```
|
||||||
// State
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Networking state + the aeronet connection entity, once wired.
|
|
||||||
#[derive(Resource, Default)]
|
|
||||||
pub struct Network {
|
|
||||||
/// Which process role we're running as (server vs client).
|
|
||||||
role: Role,
|
|
||||||
/// Entity representing the current aeronet session/server, if any.
|
|
||||||
///
|
|
||||||
/// TODO(aeronet): filled by the connection/disconnection observers below.
|
|
||||||
/// The entity *is* the peer identifier — there is no `ClientId` in aeronet.
|
|
||||||
session: Option<Entity>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
enum Role {
|
|
||||||
#[default]
|
|
||||||
None,
|
|
||||||
Host,
|
|
||||||
Client,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// TODO(aeronet): transport lanes must be declared up front. Add these (and a
|
|
||||||
/// `LaneKind` import) when the crate lands. Boilerplate an order-of-magnitude
|
|
||||||
/// easier done with the crate than comments:
|
|
||||||
///
|
|
||||||
/// ```ignore
|
|
||||||
/// const LANES: [LaneKind; 2] = [
|
|
||||||
/// LaneKind::ReliableOrdered, // game state / actions
|
|
||||||
/// LaneKind::UnreliableOrdered, // e.g. position snapshots
|
|
||||||
/// ];
|
|
||||||
/// const SEND_LANE: LaneIndex = LaneIndex::new(0);
|
|
||||||
/// ```
|
|
||||||
// #[allow(dead_code)]
|
// #[allow(dead_code)]
|
||||||
// const _: () = ();
|
// const _: () = ();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Systems
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Consume user actions and drive the (future) aeronet connection.
|
|
||||||
fn handle_actions(
|
|
||||||
mut actions: MessageReader<NetworkAction>,
|
|
||||||
mut net: ResMut<Network>,
|
|
||||||
mut ui: ResMut<NetUi>,
|
|
||||||
mut commands: Commands,
|
|
||||||
) {
|
|
||||||
for action in actions.read() {
|
|
||||||
match action {
|
|
||||||
NetworkAction::StartHost { port } => {
|
|
||||||
start_host(&mut net, &mut ui, &mut commands, *port);
|
|
||||||
}
|
|
||||||
NetworkAction::Connect { url } => {
|
|
||||||
connect(&mut net, &mut ui, &mut commands, url);
|
|
||||||
}
|
|
||||||
NetworkAction::Disconnect => {
|
|
||||||
disconnect(&mut net, &mut ui, &mut commands);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// User wants to act as a server listening on `port`.
|
|
||||||
fn start_host(net: &mut Network, ui: &mut NetUi, commands: &mut Commands, port: u16) {
|
|
||||||
net.role = Role::Host;
|
|
||||||
ui.push_log(format!("starting server on 0.0.0.0:{port}"));
|
|
||||||
|
|
||||||
// Only used once the TODO(aeronet) bodies are filled in.
|
|
||||||
let _ = commands;
|
|
||||||
|
|
||||||
// TODO(aeronet): open a server entity.
|
|
||||||
// let identity = aeronet_websocket::server::Identity::self_signed(["localhost", "127.0.0.1"])?;
|
|
||||||
// commands.spawn_empty().queue(WebSocketServer::open(
|
|
||||||
// ServerConfig::builder().with_bind(([0,0,0,0], port).into()).with_identity(identity),
|
|
||||||
// ));
|
|
||||||
// Then register observers (in the plugin's `build`):
|
|
||||||
// app.add_observer(on_opened) // On<Add, Server> -> record server entity
|
|
||||||
// .add_observer(on_client_conn) // On<Add, Session> -> insert a Transport on the session
|
|
||||||
// .add_observer(on_close) // On<Disconnected> -> clean up + push to NetUi.log
|
|
||||||
|
|
||||||
ui.status = format!("hosting on 0.0.0.0:{port} (scaffold — no listener yet)");
|
|
||||||
ui.screen = Screen::Session;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// User wants to connect to a server at `url`.
|
|
||||||
fn connect(net: &mut Network, ui: &mut NetUi, commands: &mut Commands, url: &str) {
|
|
||||||
net.role = Role::Client;
|
|
||||||
ui.push_log(format!("connecting to {url}"));
|
|
||||||
|
|
||||||
// Only used once the TODO(aeronet) bodies are filled in.
|
|
||||||
let _ = commands;
|
|
||||||
|
|
||||||
// TODO(aeronet): spawn a session entity and queue a connect.
|
|
||||||
// let config = ClientConfig::builder().with_no_cert_validation(); // demo only
|
|
||||||
// commands.spawn((TransportConfig::default(),))
|
|
||||||
// .queue(WebSocketClient::connect(config, url));
|
|
||||||
// Observers:
|
|
||||||
// on_connecting // On<Add, SessionEndpoint> -> "connecting…"
|
|
||||||
// on_connected // On<Add, Session> -> insert Transport w/ LANES, record session entity
|
|
||||||
// on_disconnected// On<Disconnected> -> log reason, clear net.session, back to menu
|
|
||||||
|
|
||||||
ui.status = format!("connecting to {url} (scaffold — no socket yet)");
|
|
||||||
ui.screen = Screen::Session;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// User wants to tear down the current connection.
|
|
||||||
fn disconnect(net: &mut Network, ui: &mut NetUi, commands: &mut Commands) {
|
|
||||||
// `commands` is only used by the TODO(aeronet) block below.
|
|
||||||
let _ = commands;
|
|
||||||
ui.push_log("disconnecting");
|
|
||||||
|
|
||||||
// TODO(aeronet): never despawn/remove manually — trigger the event so
|
|
||||||
// cleanup observers run:
|
|
||||||
// if let Some(session) = net.session {
|
|
||||||
// commands.trigger(Disconnect::new(session, "user pressed stop"));
|
|
||||||
// }
|
|
||||||
|
|
||||||
net.session = None;
|
|
||||||
net.role = Role::None;
|
|
||||||
ui.status = "idle — select an action.".into();
|
|
||||||
ui.screen = Screen::Menu;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user