From b4d340f4435656a1c3966321cf9303f6ca6a2aa3 Mon Sep 17 00:00:00 2001 From: Mitchell Date: Fri, 21 Aug 2026 17:09:02 -0500 Subject: [PATCH] main menu and game save work --- Cargo.lock | 7 +++ Cargo.toml | 1 + src/main.rs | 3 + src/main_menu.rs | 143 +++++++++++++++++++++++++++++++++++++++++++---- src/save.rs | 107 +++++++++++++++++++++++++++++++---- 5 files changed, 238 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 28e57e0..79c5eeb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5192,6 +5192,7 @@ dependencies = [ "jiff", "ron", "serde", + "xxhash-rust", ] [[package]] @@ -6209,6 +6210,12 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "xxhash-rust" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" + [[package]] name = "yazi" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index 7e45eb1..8932293 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ derive_more = { version = "2.1.1", features = ["deref", "deref_mut"] } jiff = { version = "0.2.35", features = ["serde"] } ron = "0.12.2" serde = { version = "1.0.229", features = ["derive"] } +xxhash-rust = { version = "0.8.18", features = ["xxh3", "std"] } [profile.dev] opt-level = 1 diff --git a/src/main.rs b/src/main.rs index 00bdaca..209f2dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,9 @@ mod main_menu; mod networking; mod save; +// TODO: pull from cargo.toml or something? +pub const CURRENT_GAME_VERSION: &str = "v0.0.1"; + /// 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; diff --git a/src/main_menu.rs b/src/main_menu.rs index 917055c..5f3f738 100644 --- a/src/main_menu.rs +++ b/src/main_menu.rs @@ -1,8 +1,8 @@ -use crate::save::get_saves; +use crate::save::{ActiveGameSave, create_game_save, delete_game_save, get_saves}; use crate::{AppState, WINDOW_TARGET_HEIGHT, WINDOW_TARGET_WIDTH}; use bevy::color::palettes::tailwind::SLATE_900; use bevy::input_focus::tab_navigation::TabIndex; -use bevy::text::{EditableText, TextCursorStyle}; +use bevy::text::{EditableText, EditableTextFilter, TextCursorStyle}; use bevy::ui_widgets::Button; use bevy::{prelude::*, ui_widgets::Activate}; use derive_more::{Deref, DerefMut}; @@ -22,6 +22,10 @@ impl Plugin for MainMenuPlugin { const N_LAYERS: usize = 5; +/// The text field for the name of the game. +#[derive(Component, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Debug)] +struct GameNameTextField; + /// A UI object that is selected. #[derive(Component, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Default, Debug)] struct Selected; @@ -74,16 +78,18 @@ struct MenuRoot; struct Layer(usize); /// A component that indicates the layer that this button points to. -#[derive(Component, Copy, Clone, PartialEq, Eq, Debug, Default)] +#[derive(Component, Clone, PartialEq, Eq, Debug, Default)] struct NextLayer(MenuLayer); -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Default)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] pub enum MenuLayer { #[default] Root, Play, LoadSave, NewGame, + // TODO: replace with some better game identifier, probably the game metadata + GameInfo(String), Browse, DirectConnect, Settings, @@ -121,9 +127,12 @@ impl MenuLayer { let saves = get_saves().unwrap_or_default(); let entries: Vec<_> = saves .into_iter() - .map(|m| menu_button(m.save_name, None)) + .map(|m| { + menu_button(&m.save_name, Some(MenuLayer::GameInfo(m.save_name.clone()))) + }) .collect(); + // TODO: get rid of row gap when many saves are present. Box::new(bsn! { menu_layer(layer) ChildOf(menu_root) @@ -143,6 +152,13 @@ impl MenuLayer { {new_game_menu()} ] }), + MenuLayer::GameInfo(game) => Box::new(bsn! { + full_menu_layer(layer) + ChildOf(menu_root) + Children [ + {game_info_menu(game)} + ] + }), MenuLayer::Browse => Box::new(bsn! { full_menu_layer(layer) ChildOf(menu_root) @@ -289,16 +305,18 @@ fn new_game_menu() -> impl SceneList { ), ( #NewGameTextField + GameNameTextField Node { border: Val::all(px(6)), padding: Val::all(px(12)), border_radius: BorderRadius::all(px(12)), } - // BorderColor::from(SLATE_800) BorderColor::from(SLATE_900) EditableText { cursor_width: 0.05, + max_characters: Option::Some(14), } + EditableTextFilter::new(|c| c == ' ' || c.is_ascii_alphanumeric()) TextLayout::no_wrap() TextFont { font_size: FontSize::Px(66.), @@ -315,11 +333,110 @@ fn new_game_menu() -> impl SceneList { } Children [ menu_button("Create", None) + InteractionColors::GOOD Node { margin: UiRect::left(Val::Auto), } - InteractionColors::GOOD - on(|event: On, q_text:| { info!("create!") }) + on(|_event: On, text_field: Single<&EditableText, With>| { + let value = text_field.value(); + info!("Create triggered for: {}", value); + create_game_save(value.to_string()).unwrap(); + // TODO: refresh listing + }) + ] + ), + ] +} + +fn game_info_menu(name: impl Into) -> impl SceneList { + let name = name.into(); + bsn_list![ + ( + #GameInfoTitle + Text("Load Save") + Node { + align_self: AlignSelf::Center, + } + TextFont { + // font: FontSourceTemplate::Handle("fonts/FiraSans-Bold.ttf"), + font_size: px(100.0), + } + TextColor(Color::srgb(0.9, 0.9, 0.9)) + TextShadow + ), + ( + Text("Save Name") + Node { + margin: UiRect::axes(px(12), px(0)), + } + TextFont { + // font: FontSourceTemplate::Handle("fonts/FiraSans-Bold.ttf"), + font_size: px(66.0), + } + TextColor(Color::srgb(0.9, 0.9, 0.9)) + TextShadow + ), + ( + #SaveNameTextField + GameNameTextField + Node { + border: Val::all(px(6)), + padding: Val::all(px(12)), + border_radius: BorderRadius::all(px(12)), + } + BorderColor::from(SLATE_900) + EditableText::new(name) + EditableText { + cursor_width: 0.05, + max_characters: Option::Some(14), + } + EditableTextFilter::new(|c| c == ' ' || c.is_ascii_alphanumeric()) + TextLayout::no_wrap() + TextFont { + font_size: FontSize::Px(66.), + } + TextCursorStyle { + color: Color::BLACK + } + TabIndex(0) + BackgroundColor(Color::srgb(0.48, 0.84, 0.87)) + ), + ( + Node { + margin: UiRect::top(px(48)), + } + Children [ + ( + menu_button("Delete", None) + InteractionColors::DANGER + Node { + margin: UiRect::right(Val::Auto), + } + on(|_event: On, text_field: Single<&EditableText, With>| { + let value = text_field.value(); + info!("Delete triggered for: {}", value); + delete_game_save(value.to_string()).unwrap(); + // TODO: refresh listing + }) + ), + ( + // TODO: when the user changed the name of the game, change this to a "rename" button + menu_button("Play", None) + InteractionColors::GOOD + Node { + margin: UiRect::left(Val::Auto), + } + on(|_event: On, text_field: Single<&EditableText, With>, mut commands: Commands, mut app_state: ResMut>| { + // TODO: we should definately get this value from an added + // resource. Call it something like `SelectedGameSave` + let value = text_field.value().to_string(); + info!("Play triggered for: {}", value); + commands.insert_resource(ActiveGameSave::new(value)); + app_state.set(AppState::Lobby); + + // TODO: refresh listing + }) + ) ] ), ] @@ -356,8 +473,10 @@ fn full_menu_layer(layer: usize) -> impl Scene { } } -fn menu_button(label: impl Into, next_layer: Option) -> impl Scene { - bsn! { +fn menu_button(label: impl Into, next_layer: Option) -> Box { + let label = label.into(); + + Box::new(bsn! { Button Node { width: px(600), @@ -381,7 +500,7 @@ fn menu_button(label: impl Into, next_layer: Option) -> impl TextColor(Color::srgb(0.9, 0.9, 0.9)) TextShadow )] - } + }) } fn button_activate_observer( @@ -405,7 +524,7 @@ fn button_activate_observer( menu.0.drain((layer.0 + 1)..); } // push next layer - menu.push(next_layer.0); + menu.push(next_layer.0.clone()); // update the buttons so the correct one is selected for (entity, selected) in q_selected.iter_many(layer_entries) { diff --git a/src/save.rs b/src/save.rs index 9a9841e..7ed5c20 100644 --- a/src/save.rs +++ b/src/save.rs @@ -1,7 +1,12 @@ -use app_dirs2::{AppInfo, app_dir}; +use std::path::PathBuf; + +use app_dirs2::{AppDataType, AppInfo, app_dir}; use bevy::prelude::*; use jiff::Zoned; use serde::{Deserialize, Serialize}; +use xxhash_rust::xxh3::xxh3_64; + +use crate::CURRENT_GAME_VERSION; pub const APP_INFO: AppInfo = AppInfo { name: "OfficeWarper", @@ -10,7 +15,13 @@ pub const APP_INFO: AppInfo = AppInfo { #[derive(Resource, Debug, Clone, PartialEq, Eq, Hash)] pub struct ActiveGameSave { - name: String, + pub name: String, +} +impl ActiveGameSave { + pub fn new(name: impl Into) -> Self { + let name = name.into(); + ActiveGameSave { name } + } } #[derive(Message, Debug, Copy, Clone, PartialEq, Eq, Hash)] @@ -44,19 +55,15 @@ fn handle_saves( }; info!("Saving!"); - let save_subpath = format!("saves/{}", active_game_save.name); - let save_path = match app_dir(app_dirs2::AppDataType::UserData, &APP_INFO, &*save_subpath) { - Ok(p) => p, + let save_dir = match get_save_dir(&active_game_save.name) { + Ok(d) => d, Err(e) => { - error!( - "Failed to get the save path for \"{}\": {e}", - active_game_save.name - ); + error!("Failed to get save_dir for current active save: {e}"); return; } }; - if let Err(e) = std::fs::write(save_path.join("version.txt"), "v0.0.1") { + if let Err(e) = std::fs::write(&save_dir.join("version.txt"), CURRENT_GAME_VERSION) { error!("Error when writing the game version: {e}"); return; } @@ -65,8 +72,86 @@ fn handle_saves( } } +pub fn get_save_dir(save_name: &str) -> Result { + let saves_path = match app_dir(AppDataType::UserData, &APP_INFO, "saves") { + Ok(p) => p, + Err(app_dirs2::AppDirsError::Io(e)) => { + error!("Failed to get path to the saves: {e}",); + return Err(e); + } + Err(app_dirs2::AppDirsError::NotSupported) => { + error!("The app data directory is not known on this platform!"); + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "the app data directory is not known on this platform", + )); + } + Err(app_dirs2::AppDirsError::InvalidAppInfo) => { + // :/ + panic!("APP_INFO invalid!"); + } + }; + + let save_name_hash = xxh3_64(save_name.as_bytes()); + let save_name_hash_str = format!("{:016x}", save_name_hash); + + Ok(saves_path.join(save_name_hash_str)) +} + +pub fn create_game_save(name: impl AsRef) -> Result<(), std::io::Error> { + let name = name.as_ref(); + let save_path = get_save_dir(name)?; + + match std::fs::exists(&save_path) { + Ok(true) => { + // directory already exists + return Err(std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + "The game save directory already exists!", + )); + } + Err(e) => { + return Err(e); + } + Ok(false) => {} + }; + + std::fs::create_dir_all(&save_path)?; + + let meta = GameSaveMetadata { + save_name: name.to_string(), + version: CURRENT_GAME_VERSION.to_string(), + timestamp: Zoned::now(), + }; + + let meta_ser = ron::to_string(&meta).map_err(|e| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("metadata serialization failed: {e}"), + ) + })?; + std::fs::write(save_path.join("meta.ron"), meta_ser)?; + + info!("Game save for {} created successfully!", name); + + Ok(()) +} + +pub fn delete_game_save(name: impl AsRef) -> Result<(), std::io::Error> { + let name = name.as_ref(); + let save_path = get_save_dir(name)?; + + // TODO: maybe put into a "recently deleted" folder + // Also, we probably want a confirmation + std::fs::remove_dir_all(&save_path)?; + + info!("Game save for {} deleted successfully!", name); + + Ok(()) +} + pub fn get_saves() -> Result, std::io::Error> { - let saves_path = match app_dir(app_dirs2::AppDataType::UserData, &APP_INFO, "saves") { + let saves_path = match app_dir(AppDataType::UserData, &APP_INFO, "saves") { Ok(p) => p, Err(app_dirs2::AppDirsError::Io(e)) => { error!("Failed to get path to the saves: {e}",);