Merge branch '30-pause-phase' into 'dev'

Resolve "Pause Phase"

See merge request softwaregrundprojekt/2025-2026/einzelprojekt/tutorium-moritz/bernroider-dominik/bernroider-dominik!8
This commit is contained in:
Dominik Bernroider
2025-11-24 16:55:25 +00:00
13 changed files with 390 additions and 8 deletions

View File

@@ -1,2 +1,4 @@
pub mod phase;
pub mod pom;
pub mod tile;
pub mod ui;

79
src/components/phase.rs Normal file
View File

@@ -0,0 +1,79 @@
use bevy::prelude::*;
#[derive(Debug, Clone, PartialEq)]
pub enum Phase {
Break { duration: f32 },
Focus { duration: f32 },
Paused { previous_phase: Box<Phase> },
Finished { completed_phase: Box<Phase> },
}
fn format_time(seconds: f32) -> String {
let seconds = seconds.max(0.0) as u32;
if seconds >= 3600 {
let hours = seconds / 3600;
let minutes = (seconds % 3600) / 60;
let secs = seconds % 60;
format!("{:02}:{:02}:{:02}", hours, minutes, secs)
} else {
let minutes = seconds / 60;
let secs = seconds % 60;
format!("{:02}:{:02}", minutes, secs)
}
}
impl Phase {
pub fn ui_color(&self) -> Color {
match self {
Phase::Focus { .. } => Color::srgb(0.9, 0.3, 0.3),
Phase::Break { .. } => Color::srgb(0.3, 0.8, 0.5),
Phase::Paused { .. } => Color::srgb(0.5, 0.5, 0.5),
Phase::Finished { .. } => Color::srgb(0.9, 0.8, 0.2),
}
}
pub fn display_name(&self) -> &str {
match self {
Phase::Focus { .. } => "Fokus",
Phase::Break { .. } => "Pause",
Phase::Paused { .. } => "PAUSIERT [Leertaste]",
Phase::Finished { .. } => "ABGELAUFEN [Enter]",
}
}
pub fn format_duration(&self) -> String {
match self {
Phase::Focus { duration } | Phase::Break { duration } => format_time(*duration),
Phase::Paused { previous_phase } => previous_phase.format_duration(),
Phase::Finished { .. } => "00:00".to_string(),
}
}
}
#[derive(Resource, Debug)]
pub struct CurrentPhase(pub Phase);
#[derive(Resource, Debug)]
pub struct TimerSettings {
pub focus_duration: f32,
pub short_break_duration: f32,
pub long_break_duration: f32,
pub long_break_interval: u32,
}
impl Default for TimerSettings {
fn default() -> Self {
Self {
focus_duration: 25.0 * 60.0,
short_break_duration: 5.0 * 60.0,
long_break_duration: 15.0 * 60.0,
long_break_interval: 4,
}
}
}
#[derive(Resource, Debug, Default)]
pub struct SessionTracker {
pub completed_focus_phases: u32,
}

10
src/components/ui.rs Normal file
View File

@@ -0,0 +1,10 @@
use bevy::prelude::*;
#[derive(Component)]
pub struct UiStatusRootContainer;
#[derive(Component)]
pub struct UiPhaseText;
#[derive(Component)]
pub struct UiTimerText;

View File

@@ -31,6 +31,8 @@ fn main() {
plugins::GridPlugin,
plugins::PomPlugin,
plugins::InputPlugin,
plugins::PhasePlugin,
plugins::StatusPlugin,
))
.insert_resource(config)
.run();

7
src/messages/interact.rs Normal file
View File

@@ -0,0 +1,7 @@
use bevy::prelude::*;
#[derive(Message)]
pub struct InteractStartMessage {
pub x: u32,
pub y: u32,
}

3
src/messages/mod.rs Normal file
View File

@@ -0,0 +1,3 @@
pub mod interact;
pub mod r#move;
pub mod phase;

View File

@@ -10,9 +10,3 @@ pub struct MoveMessage {
pub struct InvalidMoveMessage {
pub message: String,
}
#[derive(Message)]
pub struct InteractStartMessage {
pub x: u32,
pub y: u32,
}

13
src/messages/phase.rs Normal file
View File

@@ -0,0 +1,13 @@
use crate::components::phase::Phase;
use bevy::prelude::*;
#[derive(Message)]
pub struct PhaseTimerFinishedMessage {
pub phase: Phase,
}
#[derive(Message)]
pub struct PhaseTimerPauseMessage;
#[derive(Message)]
pub struct NextPhaseMessage;

View File

@@ -2,9 +2,14 @@ use bevy::input::mouse::MouseButton;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
use crate::components::phase::{CurrentPhase, Phase};
use crate::components::tile::{Grid, TileState};
use crate::config::GameConfig;
use crate::messages::{InteractStartMessage, InvalidMoveMessage, MoveMessage};
use crate::messages::phase::{NextPhaseMessage, PhaseTimerPauseMessage};
use crate::messages::{
interact::InteractStartMessage,
r#move::{InvalidMoveMessage, MoveMessage},
};
use crate::plugins::grid::world_to_grid_coords;
use crate::states::AppState;
@@ -21,6 +26,15 @@ impl Plugin for InputPlugin {
Update,
interact_click.run_if(in_state(AppState::GameScreen)),
);
app.add_message::<PhaseTimerPauseMessage>();
app.add_systems(
Update,
phase_timer_pause.run_if(in_state(AppState::GameScreen)),
);
app.add_message::<NextPhaseMessage>();
app.add_systems(Update, next_phase.run_if(in_state(AppState::GameScreen)));
}
}
@@ -30,7 +44,13 @@ fn move_click(
window: Single<&Window, With<PrimaryWindow>>,
camera: Single<(&Camera, &GlobalTransform), With<Camera2d>>,
config: Res<GameConfig>,
phase: Res<CurrentPhase>,
) {
match phase.0 {
Phase::Focus { .. } => return,
_ => {}
}
if mouse_btn.just_pressed(MouseButton::Right) {
let (cam, cam_transform) = *camera;
@@ -53,10 +73,16 @@ fn interact_click(
window: Single<&Window, With<PrimaryWindow>>,
camera: Single<(&Camera, &GlobalTransform), With<Camera2d>>,
config: Res<GameConfig>,
phase: Res<CurrentPhase>,
// for debug
grid: ResMut<Grid>,
tile_query: Query<&mut TileState>,
) {
match phase.0 {
Phase::Focus { .. } => return,
_ => {}
}
if mouse_btn.just_pressed(MouseButton::Left) {
let (cam, cam_transform) = *camera;
@@ -85,3 +111,18 @@ fn interact_click(
}
}
}
fn phase_timer_pause(
mut pause_messages: MessageWriter<PhaseTimerPauseMessage>,
keys: Res<ButtonInput<KeyCode>>,
) {
if keys.just_pressed(KeyCode::Space) {
pause_messages.write(PhaseTimerPauseMessage);
}
}
fn next_phase(mut messages: MessageWriter<NextPhaseMessage>, keys: Res<ButtonInput<KeyCode>>) {
if keys.just_pressed(KeyCode::Enter) {
messages.write(NextPhaseMessage);
}
}

View File

@@ -2,12 +2,16 @@ pub mod core;
pub mod game_screen;
pub mod grid;
pub mod input;
pub mod phase;
pub mod pom;
pub mod start_screen;
pub mod status;
pub use core::CorePlugin;
pub use game_screen::GameScreenPlugin;
pub use grid::GridPlugin;
pub use input::InputPlugin;
pub use phase::PhasePlugin;
pub use pom::PomPlugin;
pub use start_screen::StartScreenPlugin;
pub use status::StatusPlugin;

153
src/plugins/phase.rs Normal file
View File

@@ -0,0 +1,153 @@
use crate::{components::phase::*, messages::phase::*, states::AppState};
use bevy::prelude::*;
pub struct PhasePlugin;
impl Plugin for PhasePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<TimerSettings>();
app.init_resource::<SessionTracker>();
app.insert_resource(CurrentPhase(Phase::Focus {
duration: 25.0 * 60.0,
}));
app.add_message::<PhaseTimerFinishedMessage>();
app.add_systems(OnEnter(AppState::GameScreen), load_rules);
app.add_systems(
Update,
(tick_timer, handle_pause, handle_continue).run_if(in_state(AppState::GameScreen)),
);
#[cfg(debug_assertions)]
app.add_systems(Update, debug_short_phase_duration);
}
}
#[cfg(debug_assertions)]
fn debug_short_phase_duration(
mut phase_res: ResMut<CurrentPhase>,
keys: Res<ButtonInput<KeyCode>>,
) {
if keys.any_pressed([KeyCode::ShiftLeft, KeyCode::ShiftRight])
&& keys.just_pressed(KeyCode::Enter)
{
let phase = &mut phase_res.0;
match phase {
Phase::Focus { duration } | Phase::Break { duration } => {
*duration = 3.0;
println!("Debug: Phase duration set to 3 seconds!");
}
_ => {}
}
}
}
fn load_rules(mut phase_res: ResMut<CurrentPhase>, settings: Res<TimerSettings>) {
let phase = &mut phase_res.0;
let new_phase = match phase {
Phase::Focus { .. } => Some(Phase::Focus {
duration: settings.focus_duration,
}),
Phase::Break { .. } => Some(Phase::Break { duration: 0.0 }),
_ => None,
};
if let Some(p) = new_phase {
*phase = p;
}
}
fn tick_timer(
time: Res<Time>,
mut phase_res: ResMut<CurrentPhase>,
mut finish_writer: MessageWriter<PhaseTimerFinishedMessage>,
) {
let delta = time.delta_secs();
let phase = &mut phase_res.0;
match phase {
Phase::Focus { duration } | Phase::Break { duration } => {
*duration -= delta;
if *duration <= 0.0 {
finish_writer.write(PhaseTimerFinishedMessage {
phase: phase.clone(),
});
let completed = phase.clone();
*phase = Phase::Finished {
completed_phase: Box::new(completed),
};
println!("phase ended");
}
}
_ => {}
}
}
fn handle_pause(
mut messages: MessageReader<PhaseTimerPauseMessage>,
mut phase_res: ResMut<CurrentPhase>,
) {
for _ in messages.read() {
let phase = &mut phase_res.0;
match phase {
Phase::Focus { .. } | Phase::Break { .. } => {
let current_state = phase.clone();
*phase = Phase::Paused {
previous_phase: Box::new(current_state),
};
println!("Phase paused");
}
Phase::Paused { previous_phase } => {
*phase = *previous_phase.clone();
println!("Phase resumed");
}
_ => {}
}
}
}
fn handle_continue(
mut messages: MessageReader<NextPhaseMessage>,
mut phase_res: ResMut<CurrentPhase>,
mut session_tracker: ResMut<SessionTracker>,
settings: Res<TimerSettings>,
) {
for _ in messages.read() {
let phase = &mut phase_res.0;
if let Phase::Finished { completed_phase } = phase {
match **completed_phase {
Phase::Focus { .. } => {
session_tracker.completed_focus_phases += 1;
// TODO: add berry grant logic
let is_long_break = session_tracker.completed_focus_phases > 0
&& session_tracker.completed_focus_phases % settings.long_break_interval
== 0;
if is_long_break {
*phase = Phase::Break {
duration: settings.long_break_duration,
};
} else {
*phase = Phase::Break {
duration: settings.short_break_duration,
};
}
}
Phase::Break { .. } => {
*phase = Phase::Focus {
duration: 25.0 * 60.0,
};
}
_ => {}
}
}
}
}

View File

@@ -1,7 +1,7 @@
use crate::components::pom::{GridPosition, MovingState, PathQueue, Pom};
use crate::components::tile::{Grid, TileState};
use crate::config::GameConfig;
use crate::messages::{InvalidMoveMessage, MoveMessage};
use crate::messages::r#move::{InvalidMoveMessage, MoveMessage};
use crate::plugins::grid::{TILE_SIZE, grid_to_world_coords};
use crate::states::*;
use crate::utils::pathfinding::find_path;

74
src/plugins/status.rs Normal file
View File

@@ -0,0 +1,74 @@
use crate::{
components::{phase::CurrentPhase, ui::*},
states::AppState,
};
use bevy::prelude::*;
pub struct StatusPlugin;
impl Plugin for StatusPlugin {
fn build(&self, app: &mut App) {
app.add_systems(OnEnter(AppState::GameScreen), setup);
app.add_systems(OnExit(AppState::GameScreen), cleanup);
app.add_systems(Update, update_status.run_if(in_state(AppState::GameScreen)));
}
}
fn setup(mut commands: Commands) {
commands.spawn((
UiStatusRootContainer,
Node {
position_type: PositionType::Absolute,
bottom: px(0),
left: px(0),
width: percent(100),
height: px(50),
justify_content: JustifyContent::SpaceAround,
align_items: AlignItems::Center,
flex_direction: FlexDirection::Row,
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.8)),
children![
(
UiPhaseText,
Text::new("..."),
TextFont::from_font_size(16.0),
TextColor(Color::WHITE)
),
(
UiTimerText,
Text::new("--:--"),
TextFont::from_font_size(16.0),
TextColor(Color::WHITE)
)
],
));
}
fn update_status(
phase_res: Res<CurrentPhase>,
mut phase_query: Query<&mut Text, (With<UiPhaseText>, Without<UiTimerText>)>,
mut timer_query: Query<&mut Text, (With<UiTimerText>, Without<UiPhaseText>)>,
) {
if !phase_res.is_changed() {
return;
}
let current_phase = &phase_res.0;
if let Ok(mut phase_text) = phase_query.single_mut() {
phase_text.0 = current_phase.display_name().to_string();
}
if let Ok(mut timer_text) = timer_query.single_mut() {
timer_text.0 = current_phase.format_duration();
}
}
fn cleanup(mut commands: Commands, query: Query<Entity, With<UiStatusRootContainer>>) {
for entity in query.iter() {
commands.entity(entity).despawn();
}
}