Merge branch 'opencode/clever-wolf'
This commit is contained in:
commit
6eef94b783
15 changed files with 505 additions and 34 deletions
47
Cargo.lock
generated
47
Cargo.lock
generated
|
|
@ -1638,7 +1638,10 @@ name = "bglga"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bevy",
|
||||
"dirs",
|
||||
"fastrand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2189,6 +2192,27 @@ dependencies = [
|
|||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs"
|
||||
version = "6.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
|
||||
dependencies = [
|
||||
"dirs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirs-sys"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch"
|
||||
version = "0.2.0"
|
||||
|
|
@ -2587,7 +2611,7 @@ dependencies = [
|
|||
"vec_map",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
"windows 0.58.0",
|
||||
"windows 0.61.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3376,7 +3400,7 @@ version = "0.50.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3710,6 +3734,12 @@ version = "1.21.3"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "orbclient"
|
||||
version = "0.3.48"
|
||||
|
|
@ -4063,6 +4093,17 @@ dependencies = [
|
|||
"bitflags 2.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||
dependencies = [
|
||||
"getrandom 0.2.15",
|
||||
"libredox",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.11.1"
|
||||
|
|
@ -4172,7 +4213,7 @@ dependencies = [
|
|||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys 0.12.1",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ edition = "2021"
|
|||
[dependencies]
|
||||
bevy = "0.18"
|
||||
fastrand = "2.0.1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
dirs = "6"
|
||||
|
||||
[dev-dependencies]
|
||||
bevy = { version = "0.18", features = ["debug"] }
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ Score, lives, stage, high-score, start menu, and restart UI.
|
|||
|
||||
- [x] [GAL-51](GAL-51.md) — Display Score, Lives, and Stage in the window title.
|
||||
- [ ] [GAL-52](GAL-52.md) — Display Score, Lives, and Stage on the screen using `bevy_ui`.
|
||||
- [ ] [GAL-53](GAL-53.md) — Implement a High Score system (saving/loading).
|
||||
- [x] [GAL-53](GAL-53.md) — Implement a High Score system (saving/loading).
|
||||
- [x] [GAL-54](GAL-54.md) — Create a Start Menu state with a "Start Game" button.
|
||||
- [ ] [GAL-55](GAL-55.md) — Add a "Press R to Restart" message to the `GameOver` screen and implement restart logic.
|
||||
- [x] [GAL-56](GAL-56.md) — Score resource does not reset on game restart.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
id: GAL-53
|
||||
title: Implement a High Score system (saving/loading)
|
||||
status: Todo
|
||||
status: Done
|
||||
parent: GAL-50
|
||||
labels: [polish, ui]
|
||||
---
|
||||
|
|
@ -12,11 +12,11 @@ Implement a High Score system (saving/loading).
|
|||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] High score persists to disk under a platform-appropriate path (e.g. `dirs::data_dir()/bglga/highscore.json`).
|
||||
- [ ] Loaded on startup into a `HighScore` resource; defaults to 0 if the file is absent or corrupt (no panic).
|
||||
- [ ] Updated only when `Score > HighScore` on entering `GameOver`, then persisted.
|
||||
- [ ] Visible on Start Menu (best so far) and Game Over (best vs current).
|
||||
- [ ] Save path overridable via env var or test-only setter so tests don't pollute the user's real config.
|
||||
- [x] High score persists to disk under a platform-appropriate path (e.g. `dirs::data_dir()/bglga/highscore.json`).
|
||||
- [x] Loaded on startup into a `HighScore` resource; defaults to 0 if the file is absent or corrupt (no panic).
|
||||
- [x] Updated only when `Score > HighScore` on entering `GameOver`, then persisted.
|
||||
- [x] Visible on Start Menu (best so far) and Game Over (best vs current).
|
||||
- [x] Save path overridable via env var or test-only setter so tests don't pollute the user's real config.
|
||||
|
||||
## Integration test hints
|
||||
|
||||
|
|
@ -24,3 +24,7 @@ Implement a High Score system (saving/loading).
|
|||
- Pre-write malformed JSON; assert loader falls back to 0 and doesn't panic.
|
||||
- Run scenario: set `Score = 7000`; transition to `GameOver`; reload from disk; assert persisted value = 7000.
|
||||
- Concurrency / crash safety: write via temp file + rename, not in-place truncate.
|
||||
|
||||
## Comments
|
||||
|
||||
- 2026-05-07 — Branch `opencode/clever-wolf`, commit ff3416b — High score system implemented with persistence, UI display, and integration tests.
|
||||
|
|
|
|||
47
TODO/GAL-56.md
Normal file
47
TODO/GAL-56.md
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
id: GAL-56
|
||||
title: Score resource does not reset on game restart
|
||||
status: Done
|
||||
parent: GAL-50
|
||||
labels: [bug, ui]
|
||||
---
|
||||
|
||||
# GAL-56: Score resource does not reset on game restart
|
||||
|
||||
The `restart_game_system` in `src/game_state.rs` only resets `RestartPressed` and transitions to `Playing`. It does not reset `Score`, `PlayerLives`, `CurrentStage`, or `FormationState` resources.
|
||||
|
||||
This means on subsequent games after the first, `Score` retains the previous game's final score. The Game Over screen shows the cumulative total ("Your Score") rather than the current game's score, and `check_high_score` (from GAL-53) compares against the accumulated score instead of the current game's score.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] Restarting from `GameOver` resets `Score.value` to 0.
|
||||
- [x] Restarting resets `PlayerLives.count` to starting value.
|
||||
- [x] Restarting resets `CurrentStage.number` to 1.
|
||||
- [x] Restarting resets `FormationState` to default.
|
||||
- [x] Restarting despawns all `Enemy`, `Bullet`, `EnemyBullet`, `Explosion`, and `TractorBeam` entities.
|
||||
|
||||
## Code context
|
||||
|
||||
**`src/game_state.rs`** (restart system, current state):
|
||||
```rust
|
||||
pub fn restart_game_system(
|
||||
mut next_state: ResMut<NextState<AppState>>,
|
||||
mut restart: ResMut<RestartPressed>,
|
||||
) {
|
||||
if restart.pressed {
|
||||
restart.pressed = false;
|
||||
next_state.set(AppState::Playing);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No resource reset logic exists. The `cleanup_game_entities` system runs on `OnExit(Playing)` but does not reset resources.
|
||||
|
||||
## Comments
|
||||
|
||||
- 2026-05-09 — Status set to In Progress.
|
||||
- 2026-05-09 — Branch `opencode/clever-wolf`, commit 933d23c — Score, lives, stage, and formation reset on restart; explosions despawned on cleanup
|
||||
|
||||
## Integration test hints
|
||||
|
||||
- Build `App`, transition to `Playing`, set `Score = 5000`, transition to `GameOver`, restart, assert `Score.value == 0`.
|
||||
|
|
@ -86,6 +86,9 @@ pub struct StartButton;
|
|||
#[derive(Component)]
|
||||
pub struct RestartMessage;
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct HighScoreText;
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct Star {
|
||||
pub speed: f32,
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ use crate::constants::{
|
|||
TRACTOR_BEAM_WIDTH, WINDOW_HEIGHT, WINDOW_WIDTH,
|
||||
};
|
||||
use crate::resources::{
|
||||
AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState, StageConfigurations,
|
||||
is_special_stage,
|
||||
is_special_stage, AttackDiveTimer, CurrentStage, EnemySpawnTimer, FormationState,
|
||||
StageConfigurations,
|
||||
};
|
||||
|
||||
const BOSS_BASE_CHANCE: f32 = 0.30;
|
||||
|
|
@ -64,8 +64,8 @@ pub fn spawn_enemies(
|
|||
0.0,
|
||||
);
|
||||
|
||||
let boss_chance = BOSS_BASE_CHANCE
|
||||
+ (stage.number as f32 * BOSS_CHANCE_PER_STAGE).min(BOSS_CHANCE_BONUS_CAP);
|
||||
let boss_chance =
|
||||
BOSS_BASE_CHANCE + (stage.number as f32 * BOSS_CHANCE_PER_STAGE).min(BOSS_CHANCE_BONUS_CAP);
|
||||
let enemy_type = if fastrand::f32() < boss_chance {
|
||||
EnemyType::Boss
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,13 @@
|
|||
use bevy::prelude::*;
|
||||
|
||||
use crate::components::{Bullet, Enemy, EnemyBullet, GameOverUI, RestartMessage, StartButton, StartMenuUI};
|
||||
use crate::resources::RestartPressed;
|
||||
use crate::components::{
|
||||
Bullet, Enemy, EnemyBullet, Explosion, GameOverUI, HighScoreText, RestartMessage, StartButton,
|
||||
StartMenuUI,
|
||||
};
|
||||
use crate::constants::STARTING_LIVES;
|
||||
use crate::resources::{
|
||||
CurrentStage, FormationState, HighScore, PlayerLives, RestartPressed, Score,
|
||||
};
|
||||
|
||||
#[derive(Clone, Eq, PartialEq, Debug, Hash, Default, States)]
|
||||
pub enum AppState {
|
||||
|
|
@ -16,7 +22,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 +39,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 +78,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();
|
||||
|
|
@ -64,7 +89,15 @@ pub fn cleanup_game_over_ui(
|
|||
|
||||
pub fn cleanup_game_entities(
|
||||
mut commands: Commands,
|
||||
query: Query<Entity, Or<(With<Bullet>, With<EnemyBullet>, With<Enemy>)>>,
|
||||
query: Query<
|
||||
Entity,
|
||||
Or<(
|
||||
With<Bullet>,
|
||||
With<EnemyBullet>,
|
||||
With<Enemy>,
|
||||
With<Explosion>,
|
||||
)>,
|
||||
>,
|
||||
) {
|
||||
for entity in &query {
|
||||
commands.entity(entity).despawn();
|
||||
|
|
@ -73,7 +106,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 +132,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 +173,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();
|
||||
}
|
||||
|
|
@ -161,9 +210,170 @@ pub fn handle_restart_input(
|
|||
pub fn restart_game_system(
|
||||
mut next_state: ResMut<NextState<AppState>>,
|
||||
mut restart: ResMut<RestartPressed>,
|
||||
mut score: ResMut<Score>,
|
||||
mut lives: ResMut<PlayerLives>,
|
||||
mut stage: ResMut<CurrentStage>,
|
||||
mut formation: ResMut<FormationState>,
|
||||
) {
|
||||
if restart.pressed {
|
||||
restart.pressed = false;
|
||||
score.value = 0;
|
||||
lives.count = STARTING_LIVES;
|
||||
stage.number = 1;
|
||||
stage.waiting_for_clear = false;
|
||||
formation.next_slot_index = 0;
|
||||
formation.total_spawned_this_stage = 0;
|
||||
formation.formation_complete = false;
|
||||
next_state.set(AppState::Playing);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::components::Explosion;
|
||||
use crate::constants::STARTING_LIVES;
|
||||
use crate::resources::{CurrentStage, FormationState, PlayerLives};
|
||||
use bevy::ecs::world::World;
|
||||
|
||||
#[test]
|
||||
fn restart_game_resets_score_to_zero() {
|
||||
let mut world = World::default();
|
||||
world.insert_resource(Score { value: 5000 });
|
||||
world.insert_resource(PlayerLives { count: 1 });
|
||||
world.insert_resource(CurrentStage {
|
||||
number: 5,
|
||||
waiting_for_clear: true,
|
||||
});
|
||||
world.insert_resource(FormationState {
|
||||
next_slot_index: 4,
|
||||
total_spawned_this_stage: 10,
|
||||
formation_complete: true,
|
||||
});
|
||||
world.insert_resource(NextState::<AppState>::Pending(AppState::Playing));
|
||||
world.insert_resource(RestartPressed { pressed: true });
|
||||
|
||||
world.register_system(restart_game_system);
|
||||
world
|
||||
.run_system_cached(restart_game_system)
|
||||
.expect("system should run");
|
||||
|
||||
let score = world.resource::<Score>();
|
||||
assert_eq!(score.value, 0, "Score should be reset to 0 on restart");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_game_resets_player_lives_to_starting_lives() {
|
||||
let mut world = World::default();
|
||||
world.insert_resource(Score { value: 5000 });
|
||||
world.insert_resource(PlayerLives { count: 1 });
|
||||
world.insert_resource(CurrentStage {
|
||||
number: 1,
|
||||
waiting_for_clear: false,
|
||||
});
|
||||
world.insert_resource(FormationState::default());
|
||||
world.insert_resource(NextState::<AppState>::Pending(AppState::Playing));
|
||||
world.insert_resource(RestartPressed { pressed: true });
|
||||
|
||||
world.register_system(restart_game_system);
|
||||
world
|
||||
.run_system_cached(restart_game_system)
|
||||
.expect("system should run");
|
||||
|
||||
let lives = world.resource::<PlayerLives>();
|
||||
assert_eq!(
|
||||
lives.count, STARTING_LIVES,
|
||||
"PlayerLives should be reset to STARTING_LIVES on restart"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_game_resets_current_stage_to_defaults() {
|
||||
let mut world = World::default();
|
||||
world.insert_resource(Score { value: 0 });
|
||||
world.insert_resource(PlayerLives {
|
||||
count: STARTING_LIVES,
|
||||
});
|
||||
world.insert_resource(CurrentStage {
|
||||
number: 5,
|
||||
waiting_for_clear: true,
|
||||
});
|
||||
world.insert_resource(FormationState::default());
|
||||
world.insert_resource(NextState::<AppState>::Pending(AppState::Playing));
|
||||
world.insert_resource(RestartPressed { pressed: true });
|
||||
|
||||
world.register_system(restart_game_system);
|
||||
world
|
||||
.run_system_cached(restart_game_system)
|
||||
.expect("system should run");
|
||||
|
||||
let stage = world.resource::<CurrentStage>();
|
||||
assert_eq!(
|
||||
stage.number, 1,
|
||||
"CurrentStage.number should be reset to 1 on restart"
|
||||
);
|
||||
assert_eq!(
|
||||
stage.waiting_for_clear, false,
|
||||
"CurrentStage.waiting_for_clear should be reset to false on restart"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_game_resets_formation_state_to_defaults() {
|
||||
let mut world = World::default();
|
||||
world.insert_resource(Score { value: 0 });
|
||||
world.insert_resource(PlayerLives {
|
||||
count: STARTING_LIVES,
|
||||
});
|
||||
world.insert_resource(CurrentStage {
|
||||
number: 1,
|
||||
waiting_for_clear: false,
|
||||
});
|
||||
world.insert_resource(FormationState {
|
||||
next_slot_index: 4,
|
||||
total_spawned_this_stage: 10,
|
||||
formation_complete: true,
|
||||
});
|
||||
world.insert_resource(NextState::<AppState>::Pending(AppState::Playing));
|
||||
world.insert_resource(RestartPressed { pressed: true });
|
||||
|
||||
world.register_system(restart_game_system);
|
||||
world
|
||||
.run_system_cached(restart_game_system)
|
||||
.expect("system should run");
|
||||
|
||||
let formation = world.resource::<FormationState>();
|
||||
assert_eq!(
|
||||
formation.next_slot_index, 0,
|
||||
"FormationState.next_slot_index should be reset to 0"
|
||||
);
|
||||
assert_eq!(
|
||||
formation.total_spawned_this_stage, 0,
|
||||
"FormationState.total_spawned_this_stage should be reset to 0"
|
||||
);
|
||||
assert_eq!(
|
||||
formation.formation_complete, false,
|
||||
"FormationState.formation_complete should be reset to false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cleanup_game_entities_despawns_explosions() {
|
||||
let mut world = World::default();
|
||||
world.spawn(Explosion {
|
||||
timer: Timer::from_seconds(0.4, TimerMode::Once),
|
||||
});
|
||||
|
||||
world.register_system(cleanup_game_entities);
|
||||
world
|
||||
.run_system_cached(cleanup_game_entities)
|
||||
.expect("system should run");
|
||||
|
||||
let mut query = world.query::<&Explosion>();
|
||||
let explosion_count = query.iter(&world).count();
|
||||
assert_eq!(
|
||||
explosion_count, 0,
|
||||
"Explosion entities should be despawned during cleanup"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
18
src/lib.rs
18
src/lib.rs
|
|
@ -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,
|
||||
|
|
|
|||
73
src/persistence.rs
Normal file
73
src/persistence.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::resources::HighScore;
|
||||
|
||||
pub fn save_path() -> PathBuf {
|
||||
if let Some(p) = SAVE_PATH_OVERRIDE.get() {
|
||||
return p.clone();
|
||||
}
|
||||
if let Ok(env_path) = std::env::var("BGLGA_HIGHSCORE_PATH") {
|
||||
return PathBuf::from(env_path);
|
||||
}
|
||||
dirs::data_dir()
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default())
|
||||
.join("bglga")
|
||||
.join("highscore.json")
|
||||
}
|
||||
|
||||
pub fn save(score: &HighScore, path: &Path) -> Result<(), std::io::Error> {
|
||||
std::fs::create_dir_all(path.parent().unwrap_or(Path::new(".")))?;
|
||||
let content = serde_json::to_string_pretty(score)?;
|
||||
std::fs::write(path, content)
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> HighScore {
|
||||
let content = match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return HighScore { value: 0 },
|
||||
};
|
||||
serde_json::from_str::<HighScore>(&content).unwrap_or(HighScore { value: 0 })
|
||||
}
|
||||
|
||||
static SAVE_PATH_OVERRIDE: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
pub fn set_save_path_override(path: PathBuf) {
|
||||
SAVE_PATH_OVERRIDE.set(path).ok();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn save_and_load_round_trip() {
|
||||
let temp_path = std::env::temp_dir().join("highscore_roundtrip.json");
|
||||
let score = HighScore { value: 42 };
|
||||
save(&score, &temp_path).expect("save should succeed");
|
||||
let loaded = load(&temp_path);
|
||||
assert_eq!(loaded.value, 42, "loaded score should match saved score");
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_missing_file_returns_default() {
|
||||
let missing_path = std::env::temp_dir().join("highscore_nonexistent.json");
|
||||
let loaded = load(&missing_path);
|
||||
assert_eq!(
|
||||
loaded.value, 0,
|
||||
"missing file should return default HighScore"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_malformed_json_returns_default() {
|
||||
let temp_path = std::env::temp_dir().join("highscore_malformed.json");
|
||||
std::fs::write(&temp_path, "{bad json").expect("write temp file should succeed");
|
||||
let loaded = load(&temp_path);
|
||||
assert_eq!(
|
||||
loaded.value, 0,
|
||||
"malformed JSON should return default HighScore"
|
||||
);
|
||||
let _ = std::fs::remove_file(&temp_path);
|
||||
}
|
||||
}
|
||||
|
|
@ -58,10 +58,10 @@ pub fn move_player(
|
|||
|
||||
transform.translation.x += direction * player.speed * time.delta_secs();
|
||||
let half_width = PLAYER_SIZE.x / 2.0;
|
||||
transform.translation.x = transform
|
||||
.translation
|
||||
.x
|
||||
.clamp(-WINDOW_WIDTH / 2.0 + half_width, WINDOW_WIDTH / 2.0 - half_width);
|
||||
transform.translation.x = transform.translation.x.clamp(
|
||||
-WINDOW_WIDTH / 2.0 + half_width,
|
||||
WINDOW_WIDTH / 2.0 - half_width,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn handle_captured_player(
|
||||
|
|
|
|||
|
|
@ -167,3 +167,9 @@ pub struct AttackDiveTimer {
|
|||
pub struct RestartPressed {
|
||||
pub pressed: bool,
|
||||
}
|
||||
|
||||
#[derive(Resource, Default, serde::Serialize, serde::Deserialize)]
|
||||
pub struct HighScore {
|
||||
#[serde(rename = "high_score")]
|
||||
pub value: u32,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
60
tests/high_score.rs
Normal file
60
tests/high_score.rs
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
use bevy::ecs::world::World;
|
||||
use bglga::persistence;
|
||||
use bglga::resources::{HighScore, Score};
|
||||
use bglga::systems::check_high_score;
|
||||
|
||||
// ── check_high_score system ──
|
||||
|
||||
#[test]
|
||||
fn check_high_score_updates_when_score_exceeds() {
|
||||
let save_path = std::env::temp_dir().join("bglga_test_integration.json");
|
||||
let mut world = World::default();
|
||||
world.insert_resource(Score { value: 7000 });
|
||||
world.insert_resource(HighScore { value: 5000 });
|
||||
persistence::set_save_path_override(save_path.clone());
|
||||
world.register_system(check_high_score);
|
||||
world
|
||||
.run_system_cached(check_high_score)
|
||||
.expect("system should run");
|
||||
let hs = world.resource::<HighScore>();
|
||||
assert_eq!(hs.value, 7000);
|
||||
// Assert file was persisted to disk
|
||||
let content = std::fs::read_to_string(&save_path).expect("high score file should exist");
|
||||
assert!(
|
||||
content.contains("7000"),
|
||||
"persisted file should contain score 7000: {content}"
|
||||
);
|
||||
std::fs::remove_file(&save_path).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_high_score_does_not_update_when_score_equal() {
|
||||
let mut world = bevy::ecs::world::World::default();
|
||||
world.insert_resource(Score { value: 75 });
|
||||
world.insert_resource(HighScore { value: 75 });
|
||||
world.register_system(check_high_score);
|
||||
world
|
||||
.run_system_cached(check_high_score)
|
||||
.expect("system should run");
|
||||
let high_score = world.resource::<HighScore>();
|
||||
assert_eq!(
|
||||
high_score.value, 75,
|
||||
"HighScore should remain unchanged when score equals high score"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn check_high_score_does_not_update_when_score_lower() {
|
||||
let mut world = bevy::ecs::world::World::default();
|
||||
world.insert_resource(Score { value: 30 });
|
||||
world.insert_resource(HighScore { value: 99 });
|
||||
world.register_system(check_high_score);
|
||||
world
|
||||
.run_system_cached(check_high_score)
|
||||
.expect("system should run");
|
||||
let high_score = world.resource::<HighScore>();
|
||||
assert_eq!(
|
||||
high_score.value, 99,
|
||||
"HighScore should remain unchanged when score is lower"
|
||||
);
|
||||
}
|
||||
|
|
@ -110,7 +110,10 @@ fn for_stage_returns_normal_config_at_stage_4() {
|
|||
fn special_stage_has_full_formation() {
|
||||
let configs = StageConfigurations::default();
|
||||
let config = configs.for_stage(3);
|
||||
assert_eq!(config.enemy_count, 32, "Special stage should have 32 enemies");
|
||||
assert_eq!(
|
||||
config.enemy_count, 32,
|
||||
"Special stage should have 32 enemies"
|
||||
);
|
||||
assert_eq!(
|
||||
config.formation_layout.positions.len(),
|
||||
32,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue