feat: Add savegame loading (#37)

This commit is contained in:
demenik
2025-11-27 13:40:25 +01:00
parent 05859d6f9c
commit 4cbd477ed6
11 changed files with 408 additions and 103 deletions

View File

@@ -19,3 +19,4 @@ pub use pom::PomPlugin;
pub use savegame::SavegamePlugin;
pub use start_screen::StartScreenPlugin;
pub use status::StatusPlugin;
pub use ui::UiPlugin;

View File

@@ -1,11 +1,30 @@
use crate::prelude::*;
use std::fs;
use std::path::PathBuf;
#[derive(Resource)]
#[derive(Resource, Clone, Debug)]
pub struct SavegamePath(pub PathBuf);
#[derive(Debug)]
pub struct SavegameInfo {
pub path: SavegamePath,
pub index: u32,
pub total_berries: u32,
pub completed_focus: u32,
}
#[derive(Deserialize)]
struct PartialSaveData {
session_tracker: PartialSessionTracker,
}
#[derive(Deserialize)]
struct PartialSessionTracker {
completed_focus_phases: u32,
}
impl SavegamePath {
pub fn new(name: &str) -> Self {
pub fn new(index: u32) -> Self {
let base_path = get_internal_path().unwrap_or_else(|| {
println!(
"Could not determine platform-specific save directory. Falling back to `./saves/"
@@ -17,6 +36,59 @@ impl SavegamePath {
panic!("Failed to create save directory at {:?}: {}", base_path, e);
}
Self(base_path.join(name))
Self(base_path.join(format!("savegame-{}.json", index)))
}
pub fn list() -> Vec<SavegameInfo> {
let mut savegames = Vec::new();
let Some(base_path) = get_internal_path() else {
return Vec::new();
};
if !base_path.exists() {
return Vec::new();
}
let Ok(entries) = fs::read_dir(base_path) else {
return Vec::new();
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) != Some("json") {
continue;
}
let Some(file_name) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
if !file_name.starts_with("savegame-") {
continue;
}
let Ok(index) = file_name.trim_start_matches("savegame-").parse::<u32>() else {
continue;
};
let Ok(content) = fs::read_to_string(&path) else {
continue;
};
let Ok(data) = serde_json::from_str::<PartialSaveData>(&content) else {
continue;
};
savegames.push(SavegameInfo {
path: SavegamePath(path),
index,
total_berries: 0, // TODO: add total_berries
completed_focus: data.session_tracker.completed_focus_phases,
});
}
savegames.sort_by_key(|s| s.index);
savegames
}
pub fn next() -> Self {
let savegames = Self::list();
let next_index = savegames.last().map(|s| s.index + 1).unwrap_or(0);
Self::new(next_index)
}
}

View File

@@ -98,7 +98,7 @@ fn load_savegame(
mut phase: ResMut<CurrentPhase>,
mut tracker: ResMut<SessionTracker>,
mut settings: ResMut<TimerSettings>,
mut pom_query: Query<&mut GridPosition, With<Pom>>,
mut pom_query: Query<(&mut GridPosition, &mut Transform), With<Pom>>,
) {
for _ in messages.read() {
if let Ok(mut file) = File::open(&save_path.0) {
@@ -114,8 +114,15 @@ fn load_savegame(
*tracker = save_data.session_tracker;
*settings = save_data.timer_settings;
if let Ok(mut pom_pos) = pom_query.single_mut() {
if let Ok((mut pom_pos, mut pom_transform)) = pom_query.single_mut() {
*pom_pos = save_data.pom_position;
pom_transform.translation = grid_to_world_coords(
save_data.pom_position.x,
save_data.pom_position.y,
Some(1.0),
save_data.grid_width,
save_data.grid_height,
)
}
for x in 0..save_data.grid_width {

View File

@@ -0,0 +1,16 @@
use crate::prelude::*;
#[derive(Component)]
pub enum RootMarker {
MainMenu,
PopupSavegameLoad,
}
#[derive(Component)]
pub enum ButtonType {
LoadGame,
NewGame,
Settings,
PopupSavegameLoad { savegame_path: SavegamePath },
PopupClose,
}

View File

@@ -0,0 +1,5 @@
use crate::prelude::*;
pub const NORMAL_BUTTON: Color = Color::srgb(0.15, 0.15, 0.15);
pub const HOVERED_BUTTON: Color = Color::srgb(0.25, 0.25, 0.25);
pub const PRESSED_BUTTON: Color = Color::srgb(0.35, 0.75, 0.35);

View File

@@ -1,4 +1,9 @@
use crate::{features::savegame::messages::SavegameLoadMessage, prelude::*};
use components::*;
use consts::*;
pub mod components;
pub mod consts;
pub struct StartScreenPlugin;
@@ -10,25 +15,9 @@ impl Plugin for StartScreenPlugin {
}
}
#[derive(Resource)]
struct MenuData {
button_entity: Entity,
}
#[derive(Component)]
enum ButtonType {
LoadGame,
NewGame,
Settings,
}
const NORMAL_BUTTON: Color = Color::srgb(0.15, 0.15, 0.15);
const HOVERED_BUTTON: Color = Color::srgb(0.25, 0.25, 0.25);
const PRESSED_BUTTON: Color = Color::srgb(0.35, 0.75, 0.35);
fn setup(mut commands: Commands) {
let button_entity = commands
.spawn((
commands.spawn((
RootMarker::MainMenu,
Node {
width: percent(100),
height: percent(100),
@@ -40,10 +29,7 @@ fn setup(mut commands: Commands) {
children![
(
Text::new("Pomonon Garten"),
TextFont {
font_size: 64.0,
..default()
},
TextFont::from_font_size(64.0),
TextColor(Color::srgb(0.9, 0.9, 0.9))
),
(
@@ -59,10 +45,7 @@ fn setup(mut commands: Commands) {
BackgroundColor(NORMAL_BUTTON),
children![(
Text::new("Spiel laden"),
TextFont {
font_size: 33.0,
..default()
},
TextFont::from_font_size(33.0),
TextColor(Color::srgb(0.9, 0.9, 0.9))
)]
),
@@ -79,10 +62,7 @@ fn setup(mut commands: Commands) {
BackgroundColor(NORMAL_BUTTON),
children![(
Text::new("Neues Spiel"),
TextFont {
font_size: 33.0,
..default()
},
TextFont::from_font_size(33.0),
TextColor(Color::srgb(0.9, 0.9, 0.9))
)]
),
@@ -99,18 +79,134 @@ fn setup(mut commands: Commands) {
BackgroundColor(NORMAL_BUTTON),
children![(
Text::new("Einstellungen"),
TextFont {
font_size: 33.0,
..default()
},
TextFont::from_font_size(33.0),
TextColor(Color::srgb(0.9, 0.9, 0.9))
)]
)
],
))
.id();
));
}
commands.insert_resource(MenuData { button_entity });
fn spawn_load_popup(commands: &mut Commands) {
commands
.spawn((
RootMarker::PopupSavegameLoad,
Node {
position_type: PositionType::Absolute,
width: percent(100),
height: percent(100),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
ZIndex(1),
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)),
))
.with_children(|parent| {
parent
.spawn((
Node {
width: px(600.0),
height: px(500.0),
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
padding: UiRect::all(px(20.0)),
..default()
},
BackgroundColor(Color::srgb(0.2, 0.2, 0.2)),
BorderRadius::all(Val::Px(10.0)),
))
.with_children(|parent| {
parent.spawn((
Node {
width: percent(100.0),
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
margin: UiRect::bottom(px(20.0)),
..default()
},
children![
(
Text::new("Spielstand Auswahl"),
TextFont::from_font_size(40.0),
TextColor(Color::WHITE),
),
(
Button,
ButtonType::PopupClose,
Node {
width: px(40.0),
height: px(40.0),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(Color::srgb(0.8, 0.2, 0.2)),
children![(
Text::new("X"),
TextFont::from_font_size(24.0),
TextColor(Color::WHITE),
)]
)
],
));
parent
.spawn(Node {
width: percent(100),
flex_direction: FlexDirection::Column,
overflow: Overflow::scroll_y(),
margin: UiRect::all(px(20.0)),
row_gap: px(10.0),
..default()
})
.with_children(|parent| {
for savegame in SavegamePath::list() {
parent.spawn((
Button,
ButtonType::PopupSavegameLoad {
savegame_path: savegame.path.clone(),
},
Node {
width: percent(100),
height: px(80),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
..default()
},
BackgroundColor(NORMAL_BUTTON),
children![(
Node {
width: percent(100),
height: percent(100),
flex_direction: FlexDirection::Column,
..default()
},
children![
(
Text::new(format!(
"Spielstand {}",
savegame.index + 1
)),
TextFont::from_font_size(24.0),
TextColor(Color::srgb(0.9, 0.9, 0.9))
),
(
Text::new(format!(
"Beeren: {}, Fokusphasen abgeschlossen: {}",
savegame.total_berries,
savegame.completed_focus
)),
TextFont::from_font_size(18.0),
TextColor(Color::srgb(0.9, 0.9, 0.9))
)
]
)],
));
}
});
});
});
}
fn menu(
@@ -120,7 +216,8 @@ fn menu(
(&Interaction, &ButtonType, &mut BackgroundColor),
(Changed<Interaction>, With<Button>),
>,
mut load_messages: MessageWriter<SavegameLoadMessage>,
root_query: Query<(Entity, &RootMarker)>,
mut savegame_messages: MessageWriter<SavegameLoadMessage>,
) {
for (interaction, button_type, mut color) in &mut interaction_query {
match *interaction {
@@ -129,14 +226,25 @@ fn menu(
match button_type {
ButtonType::LoadGame => {
commands.insert_resource(SavegamePath::new("savegame.json"));
next_state.set(AppState::GameScreen);
load_messages.write(SavegameLoadMessage);
spawn_load_popup(&mut commands);
}
ButtonType::NewGame => {
commands.insert_resource(SavegamePath::new("savegame.json"));
commands.insert_resource(SavegamePath::next());
next_state.set(AppState::GameScreen);
}
ButtonType::PopupClose => {
for (entity, root) in root_query.iter() {
match *root {
RootMarker::PopupSavegameLoad => commands.entity(entity).despawn(),
_ => {}
}
}
}
ButtonType::PopupSavegameLoad { savegame_path } => {
commands.insert_resource(savegame_path.clone());
next_state.set(AppState::GameScreen);
savegame_messages.write(SavegameLoadMessage);
}
_ => (),
};
}
@@ -150,6 +258,8 @@ fn menu(
}
}
fn cleanup(mut commands: Commands, menu_data: Res<MenuData>) {
commands.entity(menu_data.button_entity).despawn();
fn cleanup(mut commands: Commands, query: Query<Entity, With<RootMarker>>) {
for entity in query.iter() {
commands.entity(entity).despawn();
}
}

View File

@@ -1,5 +1,12 @@
use crate::prelude::*;
#[derive(EntityEvent, Debug)]
#[entity_event(propagate, auto_propagate)]
pub struct Scroll {
pub entity: Entity,
pub delta: Vec2,
}
#[derive(Component)]
pub struct UiStatusRootContainer;

View File

@@ -0,0 +1 @@
pub const LINE_HEIGHT: f32 = 21.0;

View File

@@ -1 +1,86 @@
use crate::prelude::*;
use bevy::{input::mouse::*, picking::hover::HoverMap};
pub mod components;
pub mod consts;
pub struct UiPlugin;
impl Plugin for UiPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, scroll_events);
app.add_observer(on_scroll_handler);
}
}
fn scroll_events(
mut mouse_wheel_reader: MessageReader<MouseWheel>,
hover_map: Res<HoverMap>,
keyboard_input: Res<ButtonInput<KeyCode>>,
mut commands: Commands,
) {
for mouse_wheel in mouse_wheel_reader.read() {
let mut delta = -Vec2::new(mouse_wheel.x, mouse_wheel.y);
if mouse_wheel.unit == MouseScrollUnit::Line {
delta *= LINE_HEIGHT;
}
if keyboard_input.any_pressed([KeyCode::ControlLeft, KeyCode::ControlRight]) {
std::mem::swap(&mut delta.x, &mut delta.y);
}
for pointer_map in hover_map.values() {
for entity in pointer_map.keys().copied() {
commands.trigger(components::Scroll { entity, delta });
}
}
}
}
fn on_scroll_handler(
mut scroll: On<components::Scroll>,
mut query: Query<(&mut ScrollPosition, &Node, &ComputedNode)>,
) {
let Ok((mut scroll_position, node, computed)) = query.get_mut(scroll.entity) else {
return;
};
let max_offset = (computed.content_size() - computed.size()) * computed.inverse_scale_factor();
let delta = &mut scroll.delta;
if node.overflow.x == OverflowAxis::Scroll && delta.x != 0. {
// Is this node already scrolled all the way in the direction of the scroll?
let max = if delta.x > 0. {
scroll_position.x >= max_offset.x
} else {
scroll_position.x <= 0.
};
if !max {
scroll_position.x += delta.x;
// Consume the X portion of the scroll delta.
delta.x = 0.;
}
}
if node.overflow.y == OverflowAxis::Scroll && delta.y != 0. {
// Is this node already scrolled all the way in the direction of the scroll?
let max = if delta.y > 0. {
scroll_position.y >= max_offset.y
} else {
scroll_position.y <= 0.
};
if !max {
scroll_position.y += delta.y;
// Consume the Y portion of the scroll delta.
delta.y = 0.;
}
}
// Stop propagating when the delta is fully consumed.
if *delta == Vec2::ZERO {
scroll.propagate(false);
}
}

View File

@@ -31,6 +31,7 @@ fn main() {
features::PhasePlugin,
features::StatusPlugin,
features::SavegamePlugin,
features::UiPlugin,
))
.insert_resource(config)
.run();

View File

@@ -13,7 +13,7 @@ pub use crate::features::{
messages::{InteractStartMessage, MoveMessage},
},
savegame::components::SavegamePath,
ui::components::*,
ui::{components::*, consts::*},
};
pub use crate::utils::path::get_internal_path;
pub use bevy::prelude::*;