preliminary main menu

This commit is contained in:
2026-08-20 14:49:09 -05:00
parent 35289ce2b7
commit f4d803eb35
6 changed files with 857 additions and 413 deletions
Generated
+424 -230
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -4,7 +4,8 @@ version = "0.1.0"
edition = "2024"
[dependencies]
bevy = "0.19.0"
bevy = "0.19"
derive_more = { version = "2.1.1", features = ["deref", "deref_mut"] }
[profile.dev]
opt-level = 1
+40 -4
View File
@@ -1,12 +1,48 @@
use bevy::prelude::*;
use net_ui::NetUiPlugin;
use bevy::{camera::ScalingMode, color::palettes::css::GREEN, prelude::*};
use main_menu::MainMenuPlugin;
use networking::NetworkingPlugin;
mod net_ui;
mod main_menu;
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() {
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();
}
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,
);
}
+377
View File
@@ -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
View File
@@ -1 +0,0 @@
+14 -177
View File
@@ -1,188 +1,25 @@
//! Scaffold for the aeronet networking logic.
//!
//! **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
// ---------------------------------------------------------------------------
use bevy::prelude::*;
/// Owns all networking state and lifecycle.
pub struct NetworkingPlugin;
impl Plugin for NetworkingPlugin {
fn build(&self, app: &mut App) {
// Register the UI->networking intent channel.
app.add_message::<NetworkAction>();
// TODO(aeronet): register the IO + transport plugins once the crates are
// added. The exact set depends on which IO layer(s) the app supports:
// host -> app.add_plugins((aeronet_websocket::server::WebSocketServerPlugin, AeronetTransportPlugin));
// client-> app.add_plugins((aeronet_websocket::client::WebSocketClientPlugin, AeronetTransportPlugin));
//
// Each *_Plugin pulls in `aeronet_io` automatically but NOT transport.
app.init_resource::<Network>()
.add_systems(Update, handle_actions.after(crate::net_ui::NetUiSystems));
// TODO: impl
let _ = app;
}
}
// ---------------------------------------------------------------------------
// 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);
/// ```
// 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)]
// 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;
}