Initial work on menus

This commit is contained in:
2026-09-10 23:26:17 +02:00
parent 71820a25dd
commit 3dbda96d31
22 changed files with 1093 additions and 44 deletions
Binary file not shown.
+47
View File
@@ -0,0 +1,47 @@
(
button_styles: {
MainMenu: (
focus_outline_width_px: 4,
focus_outline_offset_px: 2,
focus_outline_color: Srgba((red: 1.0, green: 1.0, blue: 0.0, alpha: 1.0)),
normal: (
background_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
border_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
text_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
box_shadow: (),
),
press: (
background_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
border_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
text_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
box_shadow: (),
),
hover: (
background_color: Srgba((red: 0.0, green: 1.0, blue: 0.0, alpha: 1.0)),
border_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
text_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
box_shadow: (),
)
),
CharacterSelectCharacter: (
focus_outline_width_px: 4,
focus_outline_offset_px: 2,
focus_outline_color: Srgba((red: 1.0, green: 1.0, blue: 0.0, alpha: 1.0)),
normal: (
background_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
text_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
box_shadow: (),
),
press: (
background_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
text_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
box_shadow: (),
),
hover: (
background_color: Srgba((red: 0.0, green: 1.0, blue: 0.0, alpha: 1.0)),
text_color: Srgba((red: 1.0, green: 0.0, blue: 0.0, alpha: 1.0)),
box_shadow: (),
)
)
}
)
+4 -2
View File
@@ -11,7 +11,7 @@ use bevy_ggrs::RollbackFrameRate;
use enum_cycling::EnumCycle; use enum_cycling::EnumCycle;
use enum_cycling_derive::EnumCycle; use enum_cycling_derive::EnumCycle;
use crate::{player::Player, replay::StopReplayRecording}; use crate::{player::Player, replay::StopReplayRecording, AppState};
pub const PLAYER_DEBUG_ROW_COUNT: usize = 6; pub const PLAYER_DEBUG_ROW_COUNT: usize = 6;
@@ -365,7 +365,9 @@ impl Plugin for DebugToolsPlugin {
app.init_resource::<DebugSettings>() app.init_resource::<DebugSettings>()
.add_systems( .add_systems(
Update, Update,
(handle_palette_input, sync_gizmo_depth_bias).chain(), (handle_palette_input, sync_gizmo_depth_bias)
.chain()
.run_if(in_state(AppState::InMatch)),
) )
.add_systems(EguiPrimaryContextPass, draw_palette); .add_systems(EguiPrimaryContextPass, draw_palette);
} }
+2 -2
View File
@@ -73,8 +73,8 @@ impl AssetCollection for FighterManifestAssets {
} }
} }
#[derive(Resource)] #[derive(Resource, Deref)]
pub struct FighterManifestRegistry(HashMap<FighterId, Handle<FighterManifest>>); pub struct FighterManifestRegistry(pub HashMap<FighterId, Handle<FighterManifest>>);
impl FighterManifestRegistry { impl FighterManifestRegistry {
pub fn get(&self, fighter: FighterId) -> Option<&Handle<FighterManifest>> { pub fn get(&self, fighter: FighterId) -> Option<&Handle<FighterManifest>> {
+4 -2
View File
@@ -2,7 +2,7 @@ use bevy::prelude::*;
use bevy_asset_loader::asset_collection::AssetCollection; use bevy_asset_loader::asset_collection::AssetCollection;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::math::int::FGi32; use crate::{math::int::FGi32, menus::style::FGUiStyle};
#[derive(Reflect, Serialize, Deserialize, Clone)] #[derive(Reflect, Serialize, Deserialize, Clone)]
#[reflect(opaque)] #[reflect(opaque)]
@@ -34,7 +34,9 @@ pub struct GameSettings {
#[derive(AssetCollection, Resource)] #[derive(AssetCollection, Resource)]
pub struct CommonAssets { pub struct CommonAssets {
#[asset(path = "game_settings.ron")] #[asset(path = "game_settings.ron")]
game_settings: Handle<GameSettings>, pub game_settings: Handle<GameSettings>,
#[asset(path = "game.stylesheet.ron")]
pub ui_style: Handle<FGUiStyle>,
} }
impl FromWorld for GameSettings { impl FromWorld for GameSettings {
+36 -7
View File
@@ -21,6 +21,27 @@ pub use map::{
KeyboardInputMapElement, KeyboardInputMapElement,
}; };
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LocalInputSource {
Keyboard,
Gamepad(Entity),
}
/// Maps an input source to a player index
#[derive(Resource, Clone, Debug, Default, Deref, DerefMut)]
pub struct LocalInputAssignments(pub Vec<(usize, LocalInputSource)>);
impl LocalInputAssignments {
pub fn get_free_player_index(&self) -> Option<usize> {
for i in 0..16 {
if let None = self.iter().find(|(idx, _)| *idx == i) {
return Some(i);
}
}
None
}
}
use crate::game_settings::GameSettings; use crate::game_settings::GameSettings;
use crate::math::int::FGi32; use crate::math::int::FGi32;
use crate::netcode::GGRSCfg; use crate::netcode::GGRSCfg;
@@ -102,6 +123,7 @@ impl Plugin for FighterInputPlugin {
}, },
], ],
}) })
.init_resource::<LocalInputAssignments>()
.add_plugins(gamecube::GamecubeAdapterPlugin) .add_plugins(gamecube::GamecubeAdapterPlugin)
.add_systems(ReadInputs, read_local_inputs) .add_systems(ReadInputs, read_local_inputs)
.add_systems( .add_systems(
@@ -128,22 +150,29 @@ fn read_local_inputs(
input_map: Res<BaseInputMap>, input_map: Res<BaseInputMap>,
gc_ports: Res<gamecube::GcPorts>, gc_ports: Res<gamecube::GcPorts>,
gamepads: Query<&Gamepad>, gamepads: Query<&Gamepad>,
assignments: Res<LocalInputAssignments>,
local_players: Res<LocalPlayers>, local_players: Res<LocalPlayers>,
game_settings: Res<GameSettings> game_settings: Res<GameSettings>
) { ) {
let mut local_inputs = HashMap::new(); let mut local_inputs = HashMap::new();
for handle in &local_players.0 { for handle in &local_players.0 {
let gamepad = gc_ports let assigned = assignments.0.iter().find(|(player_handle, _)| player_handle == handle);
.entities let gamepad = match assigned {
.get(*handle) Some((_, LocalInputSource::Gamepad(entity))) => gamepads.get(*entity).ok(),
.copied() Some((_, LocalInputSource::Keyboard)) => None,
.flatten() None => gc_ports.entities.get(*handle).copied().flatten().and_then(|entity| gamepads.get(entity).ok()),
.and_then(|entity| gamepads.get(entity).ok()); };
let mut input_frame = match gamepad { let mut input_frame = match assigned {
Some((_, LocalInputSource::Keyboard)) => keyboard::sample_keyboard(&key, &input_map),
Some((_, LocalInputSource::Gamepad(_))) => gamepad
.map(|pad| gamepad::sample_gamepad(pad, &input_map))
.unwrap_or_default(),
None => match gamepad {
Some(pad) => gamepad::sample_gamepad(pad, &input_map), Some(pad) => gamepad::sample_gamepad(pad, &input_map),
None => keyboard::sample_keyboard(&key, &input_map), None => keyboard::sample_keyboard(&key, &input_map),
},
}; };
// preprocess frame // preprocess frame
+7 -28
View File
@@ -48,6 +48,7 @@ pub mod replay;
pub mod schedule; pub mod schedule;
pub mod scripting; pub mod scripting;
pub mod stage; pub mod stage;
pub mod menus;
#[derive(States, Debug, Clone, Copy, Eq, PartialEq, Hash, Default)] #[derive(States, Debug, Clone, Copy, Eq, PartialEq, Hash, Default)]
pub enum AppState { pub enum AppState {
@@ -59,7 +60,9 @@ pub enum AppState {
LoadStageManifests, LoadStageManifests,
PrepareStageManifests, PrepareStageManifests,
CommonAssetLoadFailed, CommonAssetLoadFailed,
Idle, MainMenu,
CharacterSelect,
StageSelect,
LoadingMatch, LoadingMatch,
PreparingMatch, PreparingMatch,
InMatch, InMatch,
@@ -96,6 +99,7 @@ impl Plugin for GamePlugin {
bevy_common_assets::ron::RonAssetPlugin::<StageManifest>::new(&["stage.ron"]), bevy_common_assets::ron::RonAssetPlugin::<StageManifest>::new(&["stage.ron"]),
bevy_common_assets::ron::RonAssetPlugin::<GameSettings>::new(&["ron"]), bevy_common_assets::ron::RonAssetPlugin::<GameSettings>::new(&["ron"]),
)) ))
.add_plugins(menus::MenuPlugin)
.add_loading_state( .add_loading_state(
LoadingState::new(AppState::LoadingMatch) LoadingState::new(AppState::LoadingMatch)
.continue_to_state(AppState::PreparingMatch) .continue_to_state(AppState::PreparingMatch)
@@ -134,7 +138,7 @@ impl Plugin for GamePlugin {
stage::manifest::prepare_stage_manifests, stage::manifest::prepare_stage_manifests,
) )
.add_systems( .add_systems(
OnEnter(AppState::Idle), OnEnter(AppState::MainMenu),
match_loading::initiate_default_match, match_loading::initiate_default_match,
) )
.add_systems( .add_systems(
@@ -142,7 +146,6 @@ impl Plugin for GamePlugin {
match_loading::prepare_match, match_loading::prepare_match,
) )
.add_systems(OnExit(AppState::InMatch), match_loading::cleanup_match) .add_systems(OnExit(AppState::InMatch), match_loading::cleanup_match)
.add_systems(Update, spin_cubes)
.insert_resource(args::Args::parse()); .insert_resource(args::Args::parse());
} }
} }
@@ -155,28 +158,4 @@ fn wait_for_asset_processor(
{ {
next_state.set(AppState::LoadCommonAssets); next_state.set(AppState::LoadCommonAssets);
} }
} }
/// Spin-rate in radians per second. Attach it in the inspector; the
/// editor sees this component with no registration code because Bevy's
/// `reflect_auto_register` picks up the `Reflect` derive. `Default` +
/// `#[reflect(Default)]` let the editor add it and the game reconstruct
/// it with sensible starting values.
#[derive(Component, Reflect, Default)]
#[reflect(Component, Default, @EditorCategory::new("Actor"))]
pub struct SpinningCube {
pub speed: f32,
}
fn spin_cubes(time: Res<Time>, mut cubes: Query<(&SpinningCube, &mut Transform)>) {
let dt = time.delta_secs();
for (cube, mut transform) in &mut cubes {
transform.rotate_y(cube.speed * dt);
}
}
#[derive(Component, Reflect, Default)]
#[reflect(Component, Default, @EditorCategory::new("Actor"))]
pub struct TestCube {
pub frog: f32,
}
-1
View File
@@ -30,7 +30,6 @@ fn main() -> AppExit {
App::new() App::new()
.add_plugins(default_plugins) .add_plugins(default_plugins)
.add_plugins(avian3d::prelude::PhysicsPlugins::default()) .add_plugins(avian3d::prelude::PhysicsPlugins::default())
.add_plugins(jackdaw_runtime::JackdawPlugin)
.add_plugins(game_test::GamePlugin) .add_plugins(game_test::GamePlugin)
.add_plugins(FreeCameraPlugin) .add_plugins(FreeCameraPlugin)
.add_plugins(EguiPlugin::default()) .add_plugins(EguiPlugin::default())
+3
View File
@@ -169,6 +169,9 @@ pub fn initiate_default_match(
} }
return; return;
} }
if !args.synctest {
return;
}
let players = (0..args.players) let players = (0..args.players)
.map(|handle| MatchPlayer { .map(|handle| MatchPlayer {
handle, handle,
+81
View File
@@ -0,0 +1,81 @@
use bevy::{input_focus::InputFocus, prelude::*, text::FontSourceTemplate, ui::auto_directional_navigation::AutoDirectionalNavigation};
use crate::{game_settings::CommonAssets, menus::style::{FGUiButtonType, FGUiStyle}};
#[derive(Component, Default, Clone)]
#[require(Button)]
pub struct FGUiButton {
pub button_type: FGUiButtonType
}
pub fn menu_button(label: &str) -> impl Scene {
bsn! {
FGUiButton
Node {
width: px(150),
height: px(65),
border: px(5),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
margin: UiRect { left: px(0), right: px(0), top: px(20), bottom: px(20) }
}
BoxShadow::new(Color::WHITE, px(0.0), px(0.0), px(10.0), px(10.0))
BorderColor::from(Color::BLACK)
BackgroundColor(Color::srgb(0.15, 0.15, 0.15))
AutoDirectionalNavigation::default()
Children [(
Text(label)
TextFont {
font: FontSourceTemplate::Handle("fonts/roboto.ttf"),
font_size: px(super::style::H3_SIZE),
}
TextColor(Color::srgb(0.9, 0.9, 0.9))
TextShadow
),
]
}
}
pub fn button_setup(query: Query<(Entity, &FGUiButton, &mut Node), Added<FGUiButton>>, common_assets: Res<CommonAssets>, styles: Res<Assets<FGUiStyle>>, mut commands: Commands) {
let styles = styles.get(&common_assets.ui_style).expect("UI styles should be loaded");
for (entity, button, mut node) in query {
styles.button_styles[&button.button_type].normal.apply(&mut commands, entity, &mut node);
}
}
pub fn button_style_system(query: Query<(Entity, &FGUiButton, &Interaction, &mut Node), Changed<Interaction>>, common_assets: Res<CommonAssets>, styles: Res<Assets<FGUiStyle>>, mut commands: Commands) {
let styles = styles.get(&common_assets.ui_style).expect("UI styles should be loaded");
for (entity, button, interaction, mut node) in query {
let button_style_to_use = match interaction {
Interaction::Pressed => &styles.button_styles[&button.button_type].press,
Interaction::Hovered => &styles.button_styles[&button.button_type].hover,
Interaction::None => &styles.button_styles[&button.button_type].normal,
};
button_style_to_use.apply(&mut commands, entity, &mut node);
}
}
pub fn button_focus_style_system(query: Query<(Entity, &FGUiButton)>, focus: Res<InputFocus>, mut commands: Commands, common_assets: Res<CommonAssets>, styles: Res<Assets<FGUiStyle>>) {
if let Some(focused_entity) = focus.get() {
if !query.contains(focused_entity) {
return;
}
let styles = styles.get(&common_assets.ui_style).expect("UI styles should be loaded");
for (entity, button) in query {
if focused_entity == entity {
let style = &styles.button_styles[&button.button_type];
commands.entity(focused_entity).insert(Outline {
width: px(style.focus_outline_width_px),
offset: px(style.focus_outline_offset_px),
color: style.focus_outline_color
});
} else {
commands.entity(entity).remove::<Outline>();
}
}
}
}
@@ -0,0 +1,41 @@
use bevy::{prelude::*, text::FontSourceTemplate};
use crate::{fighter::manifest::FighterManifest, menus::{button::FGUiButton, style::FGUiButtonType}};
#[derive(Component, Clone, Default, FromTemplate)]
pub struct CharacterBox {
fighter_manifest: Handle<FighterManifest>,
}
impl CharacterBox {
pub fn scene(manifest_handle: Handle<FighterManifest>, manifests: &Res<Assets<FighterManifest>>) -> impl Scene {
let manifest = manifests.get(&manifest_handle).expect("Fighter manifest should be valid");
bsn! {
Node {
width: px(128),
height: px(128),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::FlexEnd
}
FGUiButton {
button_type: FGUiButtonType::CharacterSelectCharacter
}
CharacterBox {
fighter_manifest: manifest_handle
}
BackgroundColor(Color::srgb(1.0, 1.0, 1.0))
Children [
Node {
width: percent(100)
}
Text("El Grande Padre")
TextFont {
font: FontSourceTemplate::Handle("fonts/roboto.ttf"),
font_size: px(crate::menus::style::TEXT_SIZE),
}
TextColor(Color::srgb(0.9, 0.9, 0.9))
BackgroundColor(Color::srgb(0.0, 0.0, 1.0))
]
}
}
}
@@ -0,0 +1,18 @@
use bevy::prelude::*;
pub struct FighterPortrait {
pub player_slot: usize
}
impl FighterPortrait {
pub fn create(player_slot: usize) -> impl Scene {
bsn![
Node {
max_width: px(300),
height: px(400),
flex_grow: 1.0
}
BackgroundColor(bevy::color::palettes::css::ORANGE)
]
}
}
+151
View File
@@ -0,0 +1,151 @@
use bevy::prelude::*;
use crate::{
fighter::manifest::{FighterManifest, FighterManifestRegistry},
input::{LocalInputAssignments, LocalInputSource},
menus::{MenuMarker, build_menu_with_props, cursor::FGMenuCursor, spawn_menu, wrap_menu},
};
pub mod character_box;
pub mod fighter_portrait;
#[derive(Component, FromTemplate)]
pub struct CharacterSelectScreen {
pub fighter_portait_container: Entity,
}
pub fn setup_character_select(
existing_menu: Option<Single<Entity, With<MenuMarker>>>,
commands: Commands,
manifest_registry: Res<FighterManifestRegistry>,
assignments: Res<LocalInputAssignments>,
manifests: Res<Assets<FighterManifest>>,
) {
let character_boxes = manifest_registry
.iter()
.map(|(_, v)| character_box::CharacterBox::scene(v.clone(), &manifests))
.collect::<Vec<_>>();
// Create existing portraits
let fighter_portraits = assignments
.iter()
.map(|(slot, _)| fighter_portrait::FighterPortrait::create(*slot))
.collect::<Vec<_>>();
let menu = bsn! {
// Characters
CharacterSelectScreen {
fighter_portait_container: #PortraitContainer
}
Node {
flex_direction: FlexDirection::Column
}
Children [
(Node {
flex_direction: FlexDirection::Column,
width: percent(100),
height: percent(100),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center
}
Children [
{character_boxes}
]
),
(
#PortraitContainer
Node {
flex_direction: FlexDirection::Row,
width: percent(100),
justify_content: JustifyContent::Center
}
Children [
{fighter_portraits}
]
)
]
};
spawn_menu(existing_menu, commands, menu);
}
pub fn create_portrait(
player_slot: usize,
container: Entity,
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
mapping: Res<LocalInputAssignments>,
) {
let entity = commands
.spawn_scene(fighter_portrait::FighterPortrait::create(player_slot))
.id();
commands.entity(container).add_child(entity);
commands.spawn(
FGMenuCursor::create_cursor(player_slot, meshes, materials, mapping)
);
}
#[derive(Event)]
pub struct FighterSlotAssigned {
pub slot: usize,
}
pub fn handle_fighter_slot_addition(
addition: On<FighterSlotAssigned>,
query: Single<&CharacterSelectScreen>,
commands: Commands,
meshes: ResMut<Assets<Mesh>>,
materials: ResMut<Assets<ColorMaterial>>,
mapping: Res<LocalInputAssignments>,
) {
let slot = addition.slot;
create_portrait(
slot,
query.fighter_portait_container,
commands,
meshes,
materials,
mapping,
);
}
pub fn css_input(
key: Res<ButtonInput<KeyCode>>,
pads: Query<(Entity, &Gamepad)>,
mut assignments: ResMut<LocalInputAssignments>,
mut commands: Commands,
) {
if key.get_pressed().len() != 0 {
if let None = assignments
.iter()
.find(|(_, source)| matches!(source, LocalInputSource::Keyboard))
{
if let Some(free_idx) = assignments.get_free_player_index() {
assignments.push((free_idx, LocalInputSource::Keyboard));
info!("New keyboard assignment");
commands.trigger(FighterSlotAssigned { slot: free_idx });
}
}
}
for (entity, pad) in pads {
if pad.any_pressed(GamepadButton::all()) {
let existed = assignments
.iter()
.find(|(_, assignment)| {
if let LocalInputSource::Gamepad(assignment_entity) = assignment {
return entity == *assignment_entity;
}
false
})
.is_some();
if !existed && let Some(free_idx) = assignments.get_free_player_index() {
assignments.push((free_idx, LocalInputSource::Gamepad(entity)));
commands.trigger(FighterSlotAssigned { slot: free_idx });
}
}
}
}
+152
View File
@@ -0,0 +1,152 @@
use bevy::{
asset::uuid::Uuid,
math::VectorSpace,
picking::pointer::{Location, PointerId, PointerInput, PointerLocation},
prelude::*,
window::{NormalizedWindowRef, WindowRef},
};
use crate::{game_settings::GameSettings, input::LocalInputAssignments, menus::input::MenuInput};
#[derive(Default)]
pub enum MenuCursorInputSource {
#[default]
Any,
Player(usize),
}
#[derive(Component, Default)]
pub struct FGMenuCursor {
pub input_source: MenuCursorInputSource,
pub velocity: Vec2,
}
impl FGMenuCursor {
pub fn create_cursor(
player_slot: usize,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ColorMaterial>>,
mapping: Res<LocalInputAssignments>,
) -> impl Bundle {
let (_, mapping) = mapping
.iter()
.find(|(slot, _)| *slot == player_slot)
.unwrap();
let material = materials.add(ColorMaterial::from_color(
bevy::color::palettes::tailwind::ROSE_600,
));
let circle_mesh = meshes.add(Circle::new(25.0));
let pointer_id = match mapping {
crate::input::LocalInputSource::Keyboard => PointerId::Mouse,
crate::input::LocalInputSource::Gamepad(_) => PointerId::Custom(Uuid::new_v4()),
};
(
FGMenuCursor {
input_source: super::cursor::MenuCursorInputSource::Player(player_slot),
..default()
},
pointer_id,
MeshMaterial2d(material),
Mesh2d(circle_mesh),
Transform::IDENTITY,
GlobalZIndex(1000),
)
}
}
pub fn cursor_input(
window: Single<Entity, With<bevy::window::PrimaryWindow>>,
query: Query<(
&mut FGMenuCursor,
&PointerLocation,
&PointerId,
)>,
key: Res<ButtonInput<KeyCode>>,
assignments: Res<LocalInputAssignments>,
game_settings: Res<GameSettings>,
time: Res<Time>,
pads: Query<(Entity, &Gamepad)>,
mut pointer_writer: MessageWriter<PointerInput>,
) {
for (mut cursor, location, pointer_id) in query {
let input = match cursor.input_source {
MenuCursorInputSource::Any => {
let mut input = MenuInput::default();
for (_, source) in assignments.iter() {
input.accumulate(&MenuInput::from_source(
source,
&key,
&pads,
&assignments,
&game_settings,
));
}
input
}
MenuCursorInputSource::Player(slot) => {
if let Some((_, source)) = assignments.iter().find(|(idx, _)| *idx == slot) {
MenuInput::from_source(source, &key, &pads, &assignments, &game_settings)
} else {
MenuInput::default()
}
}
};
let movement_screen_space = input.movement * Vec2::new(1.0, -1.0);
cursor.velocity = movement_screen_space * 500.0;
if cursor.velocity == Vec2::ZERO {
continue;
}
let old_cursor_pos = location
.location
.clone()
.map(|loc| loc.position)
.unwrap_or(Vec2::ZERO);
let cursor_pos = old_cursor_pos + cursor.velocity * time.delta_secs();
dbg!(cursor_pos);
pointer_writer.write(PointerInput {
pointer_id: *pointer_id,
location: Location {
target: bevy::camera::NormalizedRenderTarget::Window(
WindowRef::Primary
.normalize(Some(window.entity()))
.expect("Primary window should be valid"),
),
position: cursor_pos,
},
action: bevy::picking::pointer::PointerAction::Move {
delta: cursor_pos - old_cursor_pos,
},
});
//cursor.position
}
}
pub fn update_cursor_transform(
camera: Single<(&Camera, &GlobalTransform), With<IsDefaultUiCamera>>,
query: Query<(&mut Transform, &PointerLocation), With<FGMenuCursor>>,
) {
let (camera, camera_transform) = camera.into_inner();
for (mut trf, location) in query {
let cursor_pos_viewport = camera
.viewport_to_world_2d(
camera_transform,
location
.clone()
.location
.map(|d| d.position)
.unwrap_or_default(),
)
.ok()
.unwrap_or_default();
trf.translation = Vec3::new(cursor_pos_viewport.x, cursor_pos_viewport.y, 0.0);
}
}
+65
View File
@@ -0,0 +1,65 @@
use bevy::prelude::*;
use crate::{game_settings::GameSettings, input::{LocalInputAssignments, LocalInputSource}};
#[derive(Default)]
pub struct MenuInput {
pub movement: Vec2,
pub accept: bool
}
impl MenuInput {
pub fn accumulate(&mut self, other: &Self) {
self.movement += other.movement;
self.movement = self.movement.normalize();
self.accept = self.accept || other.accept;
}
pub fn from_gamepad(gamepad: &Gamepad, game_settings: &GameSettings) -> Self {
let mut x = gamepad.left_stick().x;
let mut y = gamepad.left_stick().y;
let deadzone = game_settings.input_common.stick_deadzone.to_num::<f32>();
if x.abs() < deadzone {
x = 0.0;
}
if y.abs() < deadzone {
y = 0.0;
}
MenuInput {
movement: Vec2::new(x, y),
accept: gamepad.pressed(GamepadButton::South)
}
}
pub fn from_keyboard(keeb: &ButtonInput<KeyCode>) -> Self {
let x = if keeb.pressed(KeyCode::KeyA) { -1.0 } else { 0.0 };
let x = x + if keeb.pressed(KeyCode::KeyD) { 1.0 } else { 0.0 };
let y = if keeb.pressed(KeyCode::KeyS) { -1.0 } else { 0.0 };
let y = y + if keeb.pressed(KeyCode::KeyW) { 1.0 } else { 0.0 };
let movement = Vec2::new(x, y);
MenuInput {
movement: movement,
accept: keeb.pressed(KeyCode::Enter)
}
}
pub fn from_source(source: &LocalInputSource, key: &ButtonInput<KeyCode>, gamepads: &Query<(Entity, &Gamepad)>, assignments: &LocalInputAssignments, game_settings: &GameSettings) -> Self {
match source {
LocalInputSource::Keyboard => Self::from_keyboard(key),
LocalInputSource::Gamepad(entity) => {
let gamepad = gamepads.get(*entity);
if let Ok((_, gamepad)) = gamepad {
Self::from_gamepad(gamepad, game_settings)
} else {
Self::default()
}
},
}
}
}
+55
View File
@@ -0,0 +1,55 @@
use bevy::{
prelude::*, text::FontSourceTemplate,
ui::auto_directional_navigation::AutoDirectionalNavigation,
};
use crate::{AppState, menus::{MenuMarker, button::menu_button, navigation::NavigationDefaultFocus}};
#[derive(SceneComponent, Default, Clone)]
pub struct MainMenu;
impl MainMenu {
fn scene() -> impl Scene {
bsn! {
BackgroundColor(Color::srgb(0.15, 0.0, 0.0))
Children [
(
Text("Project Shinespark")
TextFont {
font: FontSourceTemplate::Handle("fonts/roboto.ttf"),
font_size: px(super::style::H1_SIZE),
}
TextColor(Color::srgb(0.9, 0.9, 0.9))
),
(
Text("This is a beta, kuro owes me 5€")
TextFont {
font: FontSourceTemplate::Handle("fonts/roboto.ttf"),
font_size: px(super::style::H2_SIZE),
}
TextColor(Color::srgb(0.9, 0.9, 0.9))
),
(
Node {
width: percent(100),
height: percent(100),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
column_gap: px(5),
flex_direction: FlexDirection::Column,
}
Children [
(
menu_button("Versus")
on(|_event: On<Pointer<Press>>, mut next_state: ResMut<NextState<AppState>>,| next_state.set(AppState::CharacterSelect))
NavigationDefaultFocus
),
]
),
]
}
}
}
pub fn setup_main_menu(mut commands: Commands) {
}
+168
View File
@@ -0,0 +1,168 @@
use bevy::{
input_focus::{
InputFocus, InputFocusVisible,
directional_navigation::{AutoNavigationConfig, DirectionalNavigationPlugin},
},
prelude::*,
};
use bevy::prelude::*;
use crate::{
AppState,
game_settings::CommonAssets,
menus::{character_select::CharacterSelectScreen, main_menu::MainMenu, style::FGUiStyle},
};
pub mod button;
pub mod character_select;
pub mod cursor;
pub mod input;
pub mod main_menu;
pub mod navigation;
pub mod scaling;
pub mod style;
#[derive(Component, Clone, Default)]
pub struct MenuMarker {}
pub struct MenuPlugin;
pub fn build_menu_scene<S>(existing_menu: Option<Single<Entity, With<MenuMarker>>>) -> impl Scene
where
S: SceneComponent + Default + Clone + Send + Sync,
{
}
pub fn wrap_menu(menu: impl Scene) -> impl Scene {
bsn! {
#MenuRoot
MenuMarker
Node {
width: percent(100),
height: percent(100),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
flex_direction: FlexDirection::Column,
}
Children [
(
#MenuContainer
Node {
width: px(1920),
height: px(1080),
align_items: AlignItems::Center,
justify_content: JustifyContent::Center,
column_gap: vmin(5.0),
flex_direction: FlexDirection::Column,
padding: UiRect { left: vmin(5.0), right: vmin(5.0), top: vmin(5.0), bottom: vmin(5.0) }
}
menu
)
]
}
}
pub fn spawn_menu(
existing_menu: Option<Single<Entity, With<MenuMarker>>>,
mut commands: Commands,
menu: impl Scene,
) {
if let Some(entity) = existing_menu {
commands
.entity(entity.into_inner())
.remove::<Camera2d>()
.remove::<Camera>()
.despawn();
}
commands.queue_spawn_scene(wrap_menu(menu));
}
pub fn build_menu_with_props<S>(props: S::Props) -> impl Scene
where
S: SceneComponent + Default + Clone + Send + Sync,
{
S::scene(props)
}
pub fn build_menu<S>(
existing_menu: Option<Single<Entity, With<MenuMarker>>>,
mut commands: Commands,
) where
S: SceneComponent + Default + Clone + Send + Sync,
{
spawn_menu(
existing_menu,
commands,
build_menu_with_props::<S>(S::Props::default()),
);
}
impl Plugin for MenuPlugin {
fn build(&self, app: &mut App) {
app.add_systems(
OnEnter(AppState::MainMenu),
(build_menu::<MainMenu>, |mut commands: Commands| {
commands.spawn((Camera2d, IsDefaultUiCamera));
commands.spawn((
Camera2d,
Camera {
order: 1,
..default()
},
));
}),
)
.add_systems(
OnEnter(AppState::CharacterSelect),
character_select::setup_character_select,
)
.init_resource::<navigation::UINavigationActionState>()
.insert_resource(InputFocusVisible(true))
.insert_resource(GlobalUiDebugOptions {
enabled: true,
..default()
})
// Configure auto-navigation behavior
.insert_resource(AutoNavigationConfig {
// Require at least 10% overlap in perpendicular axis for cardinal directions
min_alignment_factor: 0.1,
// Don't connect nodes more than 500 pixels apart between their closest edges
max_search_distance: Some(500.0),
// Prefer nodes that are well-aligned
prefer_aligned: true,
})
.add_plugins(DirectionalNavigationPlugin)
.add_systems(
PreUpdate,
(
navigation::give_default_focus,
scaling::ui_scaling_system,
button::button_setup,
button::button_style_system,
button::button_focus_style_system,
)
.run_if(resource_exists::<CommonAssets>),
)
.add_systems(Startup, scaling::ui_update_scale_on_startup)
.add_systems(
PostUpdate,
(character_select::css_input, (cursor::cursor_input, cursor::update_cursor_transform).chain())
.run_if(in_state(AppState::CharacterSelect))
.run_if(any_with_component::<CharacterSelectScreen>),
)
.add_observer(character_select::handle_fighter_slot_addition)
.add_plugins(bevy_common_assets::ron::RonAssetPlugin::<FGUiStyle>::new(
&["stylesheet.ron"],
))
.add_systems(
Update,
(
navigation::process_inputs,
navigation::navigate,
navigation::interact_with_focused_button,
)
.chain(),
)
.init_resource::<InputFocus>();
}
}
+166
View File
@@ -0,0 +1,166 @@
use std::{collections::HashSet, time::Duration};
use bevy::{camera::NormalizedRenderTarget, input_focus::{InputFocus, InputFocusVisible}, math::CompassOctant, picking::{backend::HitData, pointer::{Location, PointerId}}, prelude::*, ui::auto_directional_navigation::AutoDirectionalNavigator};
// Action state and input handling
#[derive(Debug, PartialEq, Eq, Hash)]
enum DirectionalNavigationAction {
Up,
Down,
Left,
Right,
Select,
}
impl DirectionalNavigationAction {
fn variants() -> Vec<Self> {
vec![
DirectionalNavigationAction::Up,
DirectionalNavigationAction::Down,
DirectionalNavigationAction::Left,
DirectionalNavigationAction::Right,
DirectionalNavigationAction::Select,
]
}
fn keycode(&self) -> KeyCode {
match self {
DirectionalNavigationAction::Up => KeyCode::ArrowUp,
DirectionalNavigationAction::Down => KeyCode::ArrowDown,
DirectionalNavigationAction::Left => KeyCode::ArrowLeft,
DirectionalNavigationAction::Right => KeyCode::ArrowRight,
DirectionalNavigationAction::Select => KeyCode::Enter,
}
}
fn gamepad_button(&self) -> GamepadButton {
match self {
DirectionalNavigationAction::Up => GamepadButton::DPadUp,
DirectionalNavigationAction::Down => GamepadButton::DPadDown,
DirectionalNavigationAction::Left => GamepadButton::DPadLeft,
DirectionalNavigationAction::Right => GamepadButton::DPadRight,
DirectionalNavigationAction::Select => GamepadButton::South,
}
}
}
#[derive(Default, Resource)]
pub struct UINavigationActionState {
pressed_actions: HashSet<DirectionalNavigationAction>,
}
#[derive(Default, Component, Clone)]
pub struct NavigationDefaultFocus;
pub fn process_inputs(
mut action_state: ResMut<UINavigationActionState>,
keyboard_input: Res<ButtonInput<KeyCode>>,
gamepad_input: Query<&Gamepad>,
) {
action_state.pressed_actions.clear();
for action in DirectionalNavigationAction::variants() {
if keyboard_input.just_pressed(action.keycode()) {
action_state.pressed_actions.insert(action);
}
}
for gamepad in gamepad_input.iter() {
for action in DirectionalNavigationAction::variants() {
if gamepad.just_pressed(action.gamepad_button()) {
action_state.pressed_actions.insert(action);
}
}
}
}
const FOCUSED_BORDER: Srgba = bevy::color::palettes::tailwind::BLUE_50;
pub fn highlight_focused_element(
input_focus: Res<InputFocus>,
input_focus_visible: Res<InputFocusVisible>,
mut query: Query<(Entity, &mut BorderColor)>,
) {
for (entity, mut border_color) in query.iter_mut() {
if input_focus.get() == Some(entity) && input_focus_visible.0 {
*border_color = BorderColor::all(FOCUSED_BORDER);
} else {
*border_color = BorderColor::DEFAULT;
}
}
}
pub fn navigate(
action_state: Res<UINavigationActionState>,
mut auto_directional_navigator: AutoDirectionalNavigator,
) {
let net_east_west = action_state
.pressed_actions
.contains(&DirectionalNavigationAction::Right) as i8
- action_state
.pressed_actions
.contains(&DirectionalNavigationAction::Left) as i8;
let net_north_south = action_state
.pressed_actions
.contains(&DirectionalNavigationAction::Up) as i8
- action_state
.pressed_actions
.contains(&DirectionalNavigationAction::Down) as i8;
// Use Dir2::from_xy to convert input to direction, then convert to CompassOctant
let maybe_direction = Dir2::from_xy(net_east_west as f32, net_north_south as f32)
.ok()
.map(CompassOctant::from);
if let Some(direction) = maybe_direction {
match auto_directional_navigator.navigate(direction) {
Ok(_entity) => {
// Successfully navigated
}
Err(e) => {
error!("Navigation error: {}", e)
}
}
}
}
pub fn give_default_focus(query: Query<Entity, Added<NavigationDefaultFocus>>, mut input_focus: ResMut<InputFocus>) {
for entity in query {
input_focus.set(entity, bevy::input_focus::FocusCause::Navigated);
}
}
pub fn interact_with_focused_button(
action_state: Res<UINavigationActionState>,
input_focus: Res<InputFocus>,
mut commands: Commands,
) {
if action_state
.pressed_actions
.contains(&DirectionalNavigationAction::Select)
&& let Some(focused_entity) = input_focus.get()
{
commands.trigger(Pointer::new(
PointerId::Mouse,
Location {
target: NormalizedRenderTarget::None {
width: 0,
height: 0,
},
position: Vec2::ZERO,
},
Press {
button: PointerButton::Primary,
hit: HitData {
camera: Entity::PLACEHOLDER,
depth: 0.0,
position: None,
normal: None,
extra: None,
},
count: 1,
},
focused_entity,
));
}
}
+29
View File
@@ -0,0 +1,29 @@
use bevy::camera::CameraProjection;
use bevy::camera::Projection::Orthographic;
use bevy::prelude::*;
use bevy::window::WindowResized;
fn compute_ui_scale(window_size: Vec2) -> f32 {
const BASE_SIZE: Vec2 = Vec2::new(1920.0, 1080.0);
const BASE_ASPECT_RATIO: f32 = BASE_SIZE.x / BASE_SIZE.y;
let new_aspect_ratio = window_size.x / window_size.y;
if new_aspect_ratio > BASE_ASPECT_RATIO {
window_size.y / BASE_SIZE.y
} else {
window_size.x / BASE_SIZE.x
}
}
pub fn ui_update_scale_on_startup(window: Single<&Window>, mut ui_scale: ResMut<UiScale>) {
ui_scale.0 = compute_ui_scale(window.size());
}
pub fn ui_scaling_system(mut resize_reader: MessageReader<WindowResized>, mut ui_scale: ResMut<UiScale>) {
for event in resize_reader.read() {
let dimensions = Vec2::new(event.width, event.height);
*ui_scale = UiScale(compute_ui_scale(dimensions));
break
}
}
+62
View File
@@ -0,0 +1,62 @@
use std::collections::HashMap;
use bevy::prelude::*;
use serde::{Deserialize, Serialize};
pub const H1_SIZE: i32 = 72;
pub const H2_SIZE: i32 = 54;
pub const H3_SIZE: i32 = 40;
pub const TEXT_SIZE: i32 = 24;
#[derive(Serialize, Deserialize, Reflect, Default)]
#[serde(default)]
pub struct BoxShadowStyle {
color: Color,
offset_px_x: i32,
offset_px_y: i32,
spread_radius: f32,
blur_radius: f32
}
#[derive(Serialize, Deserialize, Reflect, Default)]
#[serde(default)]
pub struct ButtonStyle {
background_color: Color,
border_color: Color,
text_color: Color,
box_shadow: BoxShadowStyle,
border_size: i32
}
#[derive(Serialize, Deserialize, Reflect)]
pub struct ButtonStyles {
pub normal: ButtonStyle,
pub hover: ButtonStyle,
pub press: ButtonStyle,
pub focus_outline_width_px: i32,
pub focus_outline_offset_px: i32,
pub focus_outline_color: Color,
}
impl ButtonStyle {
pub fn apply(&self, commands: &mut Commands, entity: Entity, node: &mut Node) {
node.border = UiRect::all(px(self.border_size));
commands.entity(entity).insert((
BackgroundColor(self.background_color),
BorderColor::all(self.background_color),
BoxShadow::new(self.box_shadow.color, px(self.box_shadow.offset_px_x), px(self.box_shadow.offset_px_y), px(self.box_shadow.spread_radius), px(self.box_shadow.blur_radius))
));
}
}
#[derive(Reflect, Eq, PartialEq, Hash, Deserialize, Serialize, Default, Clone)]
pub enum FGUiButtonType {
#[default]
MainMenu,
CharacterSelectCharacter
}
#[derive(Asset, Resource, Reflect, Deserialize, Serialize)]
pub struct FGUiStyle {
pub button_styles: HashMap<FGUiButtonType, ButtonStyles>
}
+1 -1
View File
@@ -239,7 +239,7 @@ fn finish_playback(mut commands: Commands, mut next_state: ResMut<NextState<AppS
commands.remove_resource::<ReplayRuntime>(); commands.remove_resource::<ReplayRuntime>();
commands.remove_resource::<LoadedReplay>(); commands.remove_resource::<LoadedReplay>();
commands.insert_resource(ReplayComplete); commands.insert_resource(ReplayComplete);
next_state.set(AppState::Idle); next_state.set(AppState::MainMenu);
} }
} }
+1 -1
View File
@@ -173,7 +173,7 @@ pub fn prepare_stage_manifests(
} }
commands.insert_resource(StageManifestRegistry(registry)); commands.insert_resource(StageManifestRegistry(registry));
next_state.set(AppState::Idle); next_state.set(AppState::MainMenu);
} }
impl StageManifest { impl StageManifest {