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 crate::components::{Bullet, Enemy, EnemyBullet, GameOverUI, RestartMessage, StartButton, StartMenuUI};
use crate::resources::RestartPressed;
use crate::components::{
Bullet, Enemy, EnemyBullet, GameOverUI, HighScoreText, RestartMessage, StartButton, StartMenuUI,
};
use crate::resources::{HighScore, RestartPressed, Score};
#[derive(Clone, Eq, PartialEq, Debug, Hash, Default, States)]
pub enum AppState {
@ -16,7 +18,7 @@ const BUTTON_HOVER: Color = Color::srgb(0.2, 0.2, 0.7);
// --- 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((
Text::new("GAME OVER"),
TextFont {
@ -33,6 +35,25 @@ pub fn setup_game_over_ui(mut commands: Commands) {
},
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((
Text::new("Press R to Restart"),
TextFont {
@ -53,7 +74,7 @@ pub fn setup_game_over_ui(mut commands: Commands) {
pub fn cleanup_game_over_ui(
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 {
commands.entity(entity).despawn();
@ -73,7 +94,7 @@ pub fn cleanup_game_entities(
// --- 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
.spawn((
Node {
@ -99,6 +120,19 @@ pub fn setup_start_menu_ui(mut commands: Commands) {
..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
.spawn((
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 {
commands.entity(entity).despawn();
}

View file

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

View file

@ -2,9 +2,12 @@ use bevy::prelude::*;
use std::time::Duration;
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::resources::{CurrentStage, PlayerLives, Score, is_special_stage};
use crate::resources::{is_special_stage, CurrentStage, HighScore, PlayerLives, Score};
use crate::starfield::spawn_starfield;
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
}
// --- 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 ---
pub fn update_window_title(