Compare commits

...

2 commits

14 changed files with 5726 additions and 0 deletions

7
.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
.envrc
.direnv
# Added by cargo
/target

5087
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

7
Cargo.toml Normal file
View file

@ -0,0 +1,7 @@
[package]
name = "s10e-bevy-menus"
version = "0.1.0"
edition = "2024"
[dependencies]
bevy = "0.16.1"

View file

@ -0,0 +1,4 @@
# (TODO: Name)
This is a simple bevy menu library, that's supposed to abstract away a lot of the annoying parts of making bevy uis.

30
examples/basic.rs Normal file
View file

@ -0,0 +1,30 @@
use bevy::prelude::*;
use s10e_bevy_menus::{
MenuItemType,
menus::{FakeTrigger, Menu, Menus, SimpleMenus},
};
#[derive(States, Debug, Copy, Clone, PartialEq, Eq, Hash, Default, Reflect)]
pub enum CurrentMenu {
NotInMenus,
#[default]
MainMenu,
MenuA,
MenuB,
}
fn main() {
let menus = SimpleMenus::new(CurrentMenu::NotInMenus, None).add_menu(
CurrentMenu::MainMenu,
Menu::new().add_items(&[
&MenuItemType::Text("Hello world".to_owned()),
&MenuItemType::Button("Meoww".to_owned()),
]),
);
App::new()
.add_plugins(DefaultPlugins)
.init_state::<CurrentMenu>()
.add_plugins(menus)
.run();
}

100
flake.lock generated Normal file
View file

@ -0,0 +1,100 @@
{
"nodes": {
"fenix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"rust-analyzer-src": "rust-analyzer-src"
},
"locked": {
"lastModified": 1756622179,
"narHash": "sha256-K3CimrAcMhdDYkErd3oiWPZNaoyaGZEuvGrFuDPFMZY=",
"owner": "nix-community",
"repo": "fenix",
"rev": "0abcb15ae6279dcb40a8ae7c1ed980705245cb79",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "fenix",
"type": "github"
}
},
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1756636162,
"narHash": "sha256-mBecwgUTWRgClJYqcF+y4O1bY8PQHqeDpB+zsAn+/zA=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "37ff64b7108517f8b6ba5705ee5085eac636a249",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"fenix": "fenix",
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"rust-analyzer-src": {
"flake": false,
"locked": {
"lastModified": 1756597274,
"narHash": "sha256-wfaKRKsEVQDB7pQtAt04vRgFphkVscGRpSx3wG1l50E=",
"owner": "rust-lang",
"repo": "rust-analyzer",
"rev": "21614ed2d3279a9aa1f15c88d293e65a98991b30",
"type": "github"
},
"original": {
"owner": "rust-lang",
"ref": "nightly",
"repo": "rust-analyzer",
"type": "github"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

66
flake.nix Normal file
View file

@ -0,0 +1,66 @@
{
description = "Build a cargo project without extra checks";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
fenix = {
url = "github:nix-community/fenix";
inputs.nixpkgs.follows = "nixpkgs";
};
flake-utils.url = "github:numtide/flake-utils";
};
outputs =
{
self,
nixpkgs,
fenix,
flake-utils,
...
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
rs-toolchain =
with fenix.packages.${system};
combine [
complete.toolchain
];
in
{
devShells.default = pkgs.mkShell rec {
buildInputs = with pkgs; [
cargo-watch
pkg-config
rs-toolchain
udev
alsa-lib
glfw
freetype
vulkan-headers
vulkan-loader
vulkan-validation-layers
vulkan-tools # vulkaninfo
shaderc # GLSL to SPIRV compiler - glslc
renderdoc # Graphics debugger
tracy # Graphics profiler
vulkan-tools-lunarg
xorg.libX11
xorg.libXcursor
xorg.libXi
xorg.libXrandr # To use the x11 feature
libxkbcommon
wayland
clang
llvmPackages.bintools
];
LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath buildInputs;
};
}
);
}

9
src/cleanup.rs Normal file
View file

@ -0,0 +1,9 @@
use bevy::prelude::*;
/// Despawn/cleanup every entity with `T`.
/// See the [bevy cheatbook's cleanup example](https://bevy-cheatbook.github.io/patterns/generic-systems.html#example-cleanup).
pub fn despawn<T: Component>(mut c: Commands, q: Query<Entity, With<T>>) {
for e in q {
c.entity(e).despawn();
}
}

59
src/item.rs Normal file
View file

@ -0,0 +1,59 @@
use bevy::state::state::States;
use types::{ItemPosition, MenuItemType, OnPressAction};
pub mod types;
#[derive(Debug, Clone)]
pub(super) struct MenuItemInternal<NavState: States> {
pub(super) r#type: MenuItemType,
pub(super) pos: ItemPosition,
pub(super) action: Option<OnPressAction<NavState>>,
}
pub(super) trait IntoMenuItemInternal<NavState: States> {
fn item(&self) -> MenuItemInternal<NavState>;
}
impl<S: States, T: ?Sized + MenuItem<S>> IntoMenuItemInternal<S> for T {
fn item(&self) -> MenuItemInternal<S> {
MenuItemInternal {
r#type: self.item_type(),
pos: self.pos(),
action: self.action(),
}
}
}
pub trait MenuItem<NavState: States> {
fn item_type(&self) -> MenuItemType;
fn pos(&self) -> ItemPosition;
fn action(&self) -> Option<OnPressAction<NavState>>;
}
impl<NavState: States> MenuItem<NavState> for MenuItemType {
fn item_type(&self) -> MenuItemType {
self.clone()
}
fn pos(&self) -> ItemPosition {
ItemPosition::default()
}
fn action(&self) -> Option<OnPressAction<NavState>> {
None
}
}
impl<NavState: States> MenuItem<NavState> for (MenuItemType, OnPressAction<NavState>) {
fn item_type(&self) -> MenuItemType {
self.0.clone()
}
fn pos(&self) -> ItemPosition {
ItemPosition::default()
}
fn action(&self) -> Option<OnPressAction<NavState>> {
Some(self.1.clone())
}
}

57
src/item/types.rs Normal file
View file

@ -0,0 +1,57 @@
use std::ops::{Deref, DerefMut};
use bevy::{
ecs::{
event::Event,
system::{Commands, SystemId},
},
state::state::{FreelyMutableState, NextState, States},
};
/// positions the item in the menu
#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)]
pub enum ItemPosition {
#[default]
MainView,
North,
NorthWest,
West,
SouthWest,
South,
SouthEast,
East,
NorthEast,
}
// TODO: replace strings with custom "content"?
#[derive(Clone, Debug)]
pub enum MenuItemType {
Text(String),
Button(String),
// Subsection(Vec<MenuItemType>),
}
#[derive(Clone, Debug)]
pub enum OnPressAction<NavState: States> {
NavigateTo(NavState),
DispatchSystem(SystemId),
Multiple(Vec<OnPressAction<NavState>>),
Close,
}
impl<S: States + FreelyMutableState> OnPressAction<S> {
pub fn run(&self, c: &mut Commands, mut nav_state: &mut NextState<S>, closed_state: &S) {
dbg!(self);
match self {
OnPressAction::NavigateTo(to) => nav_state.set(to.clone()),
OnPressAction::DispatchSystem(system_id) => c.run_system(*system_id),
OnPressAction::Multiple(actions) => {
let mut actions = actions.clone();
while let Some(action) = actions.pop() {
action.run(c, nav_state, closed_state);
}
}
OnPressAction::Close => nav_state.set(closed_state.clone()),
}
}
}

25
src/lib.rs Normal file
View file

@ -0,0 +1,25 @@
#![allow(unused, reason = "Temporary.")]
#![feature(iter_collect_into)]
//! goal is a custom ui/menu library that allows simple and declarative menus.
//! bevy ui is annoying.
//!
//! TODOs:
//! - [ ] cleanup
//! - [ ] trigger game events (trigger or normal eventwriter?)
//! - [ ] styling
//! - [ ] more components
//! - [ ] more shorthands
use std::marker::PhantomData;
use bevy::{platform::collections::HashMap, prelude::*};
mod item;
pub mod plugin;
mod cleanup;
pub mod menus;
pub use item::types::*;
pub use item::*;
type ToDo = ();

77
src/menus.rs Normal file
View file

@ -0,0 +1,77 @@
use bevy::{platform::collections::HashMap, prelude::*};
use super::{IntoMenuItemInternal, ItemPosition, MenuItem, MenuItemInternal};
#[derive(States, Clone, Eq, PartialEq, Hash, Debug, Copy, Default)]
pub struct FakeTrigger;
pub type SimpleMenus<S> = Menus<FakeTrigger, S>;
#[derive(Debug, Clone)]
pub struct Menus<TriggerState, NavState>
where
TriggerState: States,
NavState: States,
{
pub(super) start: NavState,
pub(super) closed_when: NavState,
/// The state needed to trigger this set of menus
pub(super) trigger: Option<TriggerState>,
pub(super) menus: HashMap<NavState, Menu<NavState>>,
}
#[derive(Debug, Clone)]
pub struct Menu<ParentNavState: States> {
pub(super) menu_items: Vec<MenuItemInternal<ParentNavState>>,
}
impl<TriggerState, NavState> Menus<TriggerState, NavState>
where
TriggerState: States,
NavState: States + Default,
{
pub fn new(closed_when: NavState, trigger: Option<TriggerState>) -> Self {
Self {
trigger,
closed_when,
start: NavState::default(),
menus: HashMap::new(),
}
}
}
impl<T, NavState> Menus<T, NavState>
where
T: States,
NavState: States,
{
pub fn add_menu(mut self, state: NavState, menu: Menu<NavState>) -> Self {
self.menus.insert(state, menu);
self
}
}
impl<ParentNavState: States> Menu<ParentNavState> {
pub fn new() -> Self {
Self {
menu_items: Vec::new(),
}
}
/// Adds `items` in the default `ItemPosition::MainView`.
pub fn add_items(mut self, items: &[&dyn MenuItem<ParentNavState>]) -> Self {
self.add_items_positioned(ItemPosition::MainView, items)
}
pub fn add_items_positioned(
mut self,
position: ItemPosition,
items: &[&dyn MenuItem<ParentNavState>],
) -> Self {
items
.iter()
.map(|it| (*it).item())
.collect_into(&mut self.menu_items);
self
}
}

161
src/plugin.rs Normal file
View file

@ -0,0 +1,161 @@
use std::marker::PhantomData;
use bevy::{
ecs::spawn::SpawnIter, platform::collections::HashMap, prelude::*,
state::state::FreelyMutableState,
};
use crate::cleanup::despawn;
use super::{
ItemPosition, MenuItemInternal, MenuItemType, OnPressAction,
menus::{Menu, Menus},
};
mod components;
impl<TriggerState, NavState> Plugin for Menus<TriggerState, NavState>
where
TriggerState: States + Copy,
NavState: States + FreelyMutableState + Copy,
{
fn build(&self, app: &mut App) {
app.insert_state(self.start)
.insert_resource(self.get_store())
.insert_resource(MenusClosedWhen(self.closed_when))
.add_observer(build_ui::<NavState>)
.add_observer(destroy_ui::<NavState>)
// .add_observer(destroy_ui::<NavStates>)
.add_systems(Update, (update_ui_trigger::<NavState>,));
if let Some(trigger) = &self.trigger {
app.add_systems(
Update,
handle_press_actions::<NavState>.run_if(in_state(*trigger)),
)
.add_systems(OnExit(*trigger), close_menu::<NavState>);
} else {
app.add_systems(Update, handle_press_actions::<NavState>);
}
}
}
#[derive(Event)]
struct DestroyUi<S: States>(PhantomData<S>);
#[derive(Event)]
struct BuildUi<S: States>(S);
#[derive(Component)]
struct UiParent<S: States>(PhantomData<S>);
#[derive(Component)]
struct UiComponent;
#[derive(Component)]
struct Action<S: States>(OnPressAction<S>);
impl<T: States, S: States> Menus<T, S> {
fn get_store(&self) -> MenusStore<S> {
MenusStore(self.menus.clone())
}
}
fn close_menu<S: States + FreelyMutableState>(
mut c: Commands,
mut nav_state: ResMut<NextState<S>>,
closed_when: Res<MenusClosedWhen<S>>,
) {
c.run_system_cached(despawn::<UiParent<S>>);
nav_state.set(closed_when.0.clone());
}
#[derive(Resource)]
struct MenusStore<S: States>(HashMap<S, Menu<S>>);
#[derive(Resource)]
struct MenusClosedWhen<S: States>(S);
fn destroy_ui<S: States>(
trigger: Trigger<DestroyUi<S>>,
ui_parent: Query<Entity, With<UiParent<S>>>,
mut c: Commands,
) {
trace!("destroy_ui called");
if let Ok(parent) = ui_parent.single_inner() {
c.entity(parent).despawn();
}
}
fn build_ui<S: States>(
trigger: Trigger<BuildUi<S>>,
ui_parent: Query<Entity, With<UiParent<S>>>,
mut c: Commands,
menus_data: Res<MenusStore<S>>,
) {
let e = trigger.event();
let Some(menu_structure) = menus_data.0.get(&e.0) else {
return;
};
let items = menu_structure
.menu_items
.clone()
.into_iter()
// TODO: implement other item positions
.filter(|it| ItemPosition::MainView == it.pos);
c.spawn((
UiParent(PhantomData::<S>),
Node {
width: Val::Percent(100.0),
height: Val::Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
flex_direction: FlexDirection::Column,
..default()
},
))
.with_children(|parent| {
for MenuItemInternal { r#type, action, .. } in items {
let mut e = match r#type {
MenuItemType::Text(s) => parent.spawn(components::text(s)),
MenuItemType::Button(s) => parent.spawn(components::button(s)),
};
e.insert(UiComponent);
if let Some(action) = action {
e.insert(Action(action));
}
}
});
}
fn handle_press_actions<S: States + FreelyMutableState>(
mut c: Commands,
mut interaction_query: Query<(&Interaction, &Action<S>), Changed<Interaction>>,
mut nav_state: ResMut<NextState<S>>,
closed_when: Res<MenusClosedWhen<S>>,
) {
for (interaction, Action(action)) in interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
action.run(&mut c, &mut nav_state, &closed_when.0);
}
}
fn update_ui_trigger<S: States + PartialEq>(
mut c: Commands,
mut trans_reader: EventReader<StateTransitionEvent<S>>,
menus_data: Res<MenusStore<S>>,
closed_when: Res<MenusClosedWhen<S>>,
) {
for trans in trans_reader.read() {
info!("{trans:?}");
c.trigger(DestroyUi(PhantomData::<S>));
if let Some(to_build) = trans.entered.clone() {
if to_build != closed_when.0 {
c.trigger(BuildUi(to_build));
}
}
}
}

37
src/plugin/components.rs Normal file
View file

@ -0,0 +1,37 @@
use bevy::prelude::*;
use crate::OnPressAction;
use super::Action;
pub fn text(t: String) -> impl Bundle {
(
Text::new(t),
TextFont {
// font: asset_server.load("fonts/FiraSans-Bold.ttf"),
font_size: 33.0,
..default()
},
TextColor(Color::srgb(0.9, 0.9, 0.9)),
TextShadow::default(),
)
}
pub fn button(t: String) -> impl Bundle {
(
Button,
Node {
border: UiRect::all(Val::Px(5.0)),
// horizontally center child text
justify_content: JustifyContent::Center,
// vertically center child text
align_items: AlignItems::Center,
padding: UiRect::all(Val::Px(10.0)),
..default()
},
BorderColor(Color::BLACK),
BorderRadius::MAX,
BackgroundColor(Color::srgb(0.2, 0.2, 0.2)),
children![text(t)],
)
}