feat(ui): wire high score into game lifecycle and display

Add check_high_score system on GameEnter, insert HighScore resource
loaded from disk at startup, display best score on Start Menu and
Game Over screens.
This commit is contained in:
Harald Hoyer 2026-05-07 23:31:15 +02:00
parent 060a9a2a14
commit 52b5c9d7e6
3 changed files with 71 additions and 14 deletions

View file

@ -1,7 +1,9 @@
use bevy::prelude::*; use bevy::prelude::*;
use crate::components::{Bullet, Enemy, EnemyBullet, GameOverUI, RestartMessage, StartButton, StartMenuUI}; use crate::components::{
use crate::resources::RestartPressed; Bullet, Enemy, EnemyBullet, GameOverUI, HighScoreText, RestartMessage, StartButton, StartMenuUI,
};
use crate::resources::{HighScore, RestartPressed, Score};
#[derive(Clone, Eq, PartialEq, Debug, Hash, Default, States)] #[derive(Clone, Eq, PartialEq, Debug, Hash, Default, States)]
pub enum AppState { pub enum AppState {
@ -16,7 +18,7 @@ const BUTTON_HOVER: Color = Color::srgb(0.2, 0.2, 0.7);
// --- Game Over UI --- // --- Game Over UI ---
pub fn setup_game_over_ui(mut commands: Commands) { pub fn setup_game_over_ui(mut commands: Commands, high_score: Res<HighScore>, score: Res<Score>) {
commands.spawn(( commands.spawn((
Text::new("GAME OVER"), Text::new("GAME OVER"),
TextFont { TextFont {
@ -33,6 +35,25 @@ pub fn setup_game_over_ui(mut commands: Commands) {
}, },
GameOverUI, GameOverUI,
)); ));
commands.spawn((
Text::new(format!(
"Best: {} Your Score: {}",
high_score.value, score.value
)),
TextFont {
font_size: 32.0,
..default()
},
TextColor(Color::WHITE),
Node {
position_type: PositionType::Absolute,
align_self: AlignSelf::Center,
justify_self: JustifySelf::Center,
top: Val::Percent(48.0),
..default()
},
HighScoreText,
));
commands.spawn(( commands.spawn((
Text::new("Press R to Restart"), Text::new("Press R to Restart"),
TextFont { TextFont {
@ -53,7 +74,7 @@ pub fn setup_game_over_ui(mut commands: Commands) {
pub fn cleanup_game_over_ui( pub fn cleanup_game_over_ui(
mut commands: Commands, mut commands: Commands,
query: Query<Entity, Or<(With<GameOverUI>, With<RestartMessage>)>>, query: Query<Entity, Or<(With<GameOverUI>, With<RestartMessage>, With<HighScoreText>)>>,
) { ) {
for entity in &query { for entity in &query {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
@ -73,7 +94,7 @@ pub fn cleanup_game_entities(
// --- Start Menu UI --- // --- Start Menu UI ---
pub fn setup_start_menu_ui(mut commands: Commands) { pub fn setup_start_menu_ui(mut commands: Commands, high_score: Res<HighScore>) {
commands commands
.spawn(( .spawn((
Node { Node {
@ -99,6 +120,19 @@ pub fn setup_start_menu_ui(mut commands: Commands) {
..default() ..default()
}, },
)); ));
parent.spawn((
Text::new(format!("Best: {}", high_score.value)),
TextFont {
font_size: 32.0,
..default()
},
TextColor(Color::WHITE),
Node {
margin: UiRect::bottom(Val::Px(30.0)),
..default()
},
HighScoreText,
));
parent parent
.spawn(( .spawn((
Button, Button,
@ -127,7 +161,10 @@ pub fn setup_start_menu_ui(mut commands: Commands) {
}); });
} }
pub fn cleanup_start_menu_ui(mut commands: Commands, query: Query<Entity, With<StartMenuUI>>) { pub fn cleanup_start_menu_ui(
mut commands: Commands,
query: Query<Entity, Or<(With<StartMenuUI>, With<HighScoreText>)>>,
) {
for entity in &query { for entity in &query {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }

View file

@ -8,6 +8,7 @@ pub mod components;
pub mod constants; pub mod constants;
pub mod enemy; pub mod enemy;
pub mod game_state; pub mod game_state;
pub mod persistence;
pub mod player; pub mod player;
pub mod resources; pub mod resources;
pub mod stage; pub mod stage;
@ -15,8 +16,7 @@ pub mod starfield;
pub mod systems; pub mod systems;
use bullet::{ use bullet::{
check_bullet_collisions, check_enemy_bullet_player_collisions, move_bullets, check_bullet_collisions, check_enemy_bullet_player_collisions, move_bullets, move_enemy_bullets,
move_enemy_bullets,
}; };
use components::TractorBeam; use components::TractorBeam;
use constants::{PLAYER_RESPAWN_DELAY, STARTING_LIVES, WINDOW_HEIGHT, WINDOW_WIDTH}; use constants::{PLAYER_RESPAWN_DELAY, STARTING_LIVES, WINDOW_HEIGHT, WINDOW_WIDTH};
@ -34,14 +34,14 @@ use player::{
player_shoot, respawn_player, player_shoot, respawn_player,
}; };
use resources::{ use resources::{
AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, PlayerLives, AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, HighScore, PlayerLives,
PlayerRespawnTimer, RestartPressed, Score, StageConfigurations, PlayerRespawnTimer, RestartPressed, Score, StageConfigurations,
}; };
use stage::check_stage_clear; use stage::check_stage_clear;
use starfield::scroll_starfield; use starfield::scroll_starfield;
use systems::{ use systems::{
animate_explosion, player_exists, player_vulnerable, setup, should_respawn_player, animate_explosion, check_high_score, player_exists, player_vulnerable, setup,
update_window_title, should_respawn_player, update_window_title,
}; };
pub fn run() { pub fn run() {
@ -73,6 +73,9 @@ pub fn run() {
timer: Timer::new(Duration::from_secs_f32(3.0), TimerMode::Once), timer: Timer::new(Duration::from_secs_f32(3.0), TimerMode::Once),
}) })
.init_resource::<Score>() .init_resource::<Score>()
.insert_resource(HighScore {
value: persistence::load(&persistence::save_path()).value,
})
.init_resource::<CurrentStage>() .init_resource::<CurrentStage>()
.init_resource::<FormationState>() .init_resource::<FormationState>()
.init_resource::<StageConfigurations>() .init_resource::<StageConfigurations>()
@ -121,7 +124,10 @@ pub fn run() {
start_menu_button_system.run_if(in_state(AppState::StartMenu)), start_menu_button_system.run_if(in_state(AppState::StartMenu)),
) )
// Game over. // Game over.
.add_systems(OnEnter(AppState::GameOver), setup_game_over_ui) .add_systems(
OnEnter(AppState::GameOver),
(setup_game_over_ui, check_high_score),
)
.add_systems(OnExit(AppState::GameOver), cleanup_game_over_ui) .add_systems(OnExit(AppState::GameOver), cleanup_game_over_ui)
.add_systems( .add_systems(
Update, Update,

View file

@ -2,9 +2,12 @@ use bevy::prelude::*;
use std::time::Duration; use std::time::Duration;
use crate::components::{Explosion, Invincible, Player}; use crate::components::{Explosion, Invincible, Player};
use crate::constants::{EXPLOSION_BASE_SIZE, EXPLOSION_COLOR, EXPLOSION_DURATION, EXPLOSION_MAX_SIZE}; use crate::constants::{
EXPLOSION_BASE_SIZE, EXPLOSION_COLOR, EXPLOSION_DURATION, EXPLOSION_MAX_SIZE,
};
use crate::persistence;
use crate::player::spawn_player_ship; use crate::player::spawn_player_ship;
use crate::resources::{CurrentStage, PlayerLives, Score, is_special_stage}; use crate::resources::{is_special_stage, CurrentStage, HighScore, PlayerLives, Score};
use crate::starfield::spawn_starfield; use crate::starfield::spawn_starfield;
pub fn setup(mut commands: Commands) { pub fn setup(mut commands: Commands) {
@ -27,6 +30,17 @@ pub fn should_respawn_player(lives: Res<PlayerLives>, query: Query<&Player>) ->
query.is_empty() && lives.count > 0 query.is_empty() && lives.count > 0
} }
// --- High Score ---
pub fn check_high_score(score: Res<Score>, mut high_score: ResMut<HighScore>) {
if score.value > high_score.value {
high_score.value = score.value;
if let Err(e) = persistence::save(&high_score, &persistence::save_path()) {
eprintln!("Failed to save high score: {e}");
}
}
}
// --- HUD --- // --- HUD ---
pub fn update_window_title( pub fn update_window_title(