feat(engine): convert GameOverScreen to real-button modal

Phase 3 step 4b of the UX overhaul. Same shape as the Confirm modal
conversion (3f922ed): replace plain "Press N for new game" /
"Press G to forfeit" text hints with real Button entities, hover
and press feedback included.

The audit flagged the Game Over overlay as the second instance of
the "feels like a debug panel" problem. Players had to know the
hotkeys to escape the screen — there was no clickable affordance.

Modal contents:
- Header: "No more moves available"
- Body:   "Final score: {N}" (TYPE_BODY_LG, TEXT_PRIMARY)
- Actions:
    Undo (Secondary, hotkey "U")        — left
    New Game (Primary yellow, hotkey "N") — right

The G/forfeit hint is dropped from the modal because:
1. Forfeit is handled globally by `input_plugin::handle_forfeit`
   (which works whether the modal is up or not).
2. The proposal calls for replacing the toast-countdown forfeit
   flow with its own modal in step 4c (next commit).

A new `handle_game_over_button_input` system mirrors the keyboard
handler for clicks. Existing N/Esc and U accelerators continue to
work via the original `handle_game_over_input`.

The `game_over_screen_text_content` test is updated to assert the
new button-label / hotkey-chip strings instead of the prior prose
hints. All 797 tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-30 01:01:39 +00:00
parent 3f922ede28
commit 242b5fef21
+97 -76
View File
@@ -102,6 +102,7 @@ impl Plugin for GamePlugin {
.add_systems(Update, handle_confirm_input.after(GameMutation)) .add_systems(Update, handle_confirm_input.after(GameMutation))
.add_systems(Update, handle_confirm_button_input.after(GameMutation)) .add_systems(Update, handle_confirm_button_input.after(GameMutation))
.add_systems(Update, handle_game_over_input.after(GameMutation)) .add_systems(Update, handle_game_over_input.after(GameMutation))
.add_systems(Update, handle_game_over_button_input.after(GameMutation))
.init_resource::<AutoSaveTimer>() .init_resource::<AutoSaveTimer>()
.add_systems(Update, tick_elapsed_time) .add_systems(Update, tick_elapsed_time)
.add_systems(Update, auto_save_game_state) .add_systems(Update, auto_save_game_state)
@@ -537,6 +538,7 @@ pub fn has_legal_moves(game: &GameState) -> bool {
/// spawns a `GameOverScreen` overlay. The overlay is despawned automatically /// spawns a `GameOverScreen` overlay. The overlay is despawned automatically
/// when `has_legal_moves` returns true again (e.g. after undo) or when the /// when `has_legal_moves` returns true again (e.g. after undo) or when the
/// game is won. /// game is won.
#[allow(clippy::too_many_arguments)]
fn check_no_moves( fn check_no_moves(
mut commands: Commands, mut commands: Commands,
mut events: MessageReader<StateChangedEvent>, mut events: MessageReader<StateChangedEvent>,
@@ -544,6 +546,7 @@ fn check_no_moves(
mut toast: MessageWriter<InfoToastEvent>, mut toast: MessageWriter<InfoToastEvent>,
mut already_fired: Local<bool>, mut already_fired: Local<bool>,
game_over_screens: Query<Entity, With<GameOverScreen>>, game_over_screens: Query<Entity, With<GameOverScreen>>,
font_res: Option<Res<FontResource>>,
) { ) {
// Reset the debounce flag on every state change so if something changes // Reset the debounce flag on every state change so if something changes
// we re-evaluate on the next state change. // we re-evaluate on the next state change.
@@ -577,84 +580,64 @@ fn check_no_moves(
*already_fired = true; *already_fired = true;
// Only spawn the overlay if one does not already exist. // Only spawn the overlay if one does not already exist.
if game_over_screens.is_empty() { if game_over_screens.is_empty() {
spawn_game_over_screen(&mut commands, game.0.score); spawn_game_over_screen(&mut commands, game.0.score, font_res.as_deref());
} }
} }
} }
/// Spawns the full-screen game-over overlay with score display and action hints. /// Marker on the "Undo" secondary button inside the game-over modal.
#[derive(Component, Debug)]
pub struct GameOverUndoButton;
/// Marker on the "New Game" primary button inside the game-over modal.
#[derive(Component, Debug)]
pub struct GameOverNewGameButton;
/// Spawns the game-over modal using the standard `ui_modal` primitive.
/// ///
/// The background is intentionally semi-transparent (alpha 0.6) so the stuck /// Replaces a bespoke layout that listed action hints as plain text
/// card layout remains visible behind the dialog. /// ("Press N for a new game", "Press G to forfeit") — the audit
fn spawn_game_over_screen(commands: &mut Commands, score: i32) { /// flagged this as the same class of "feels like a debug panel"
commands /// problem the confirm modal had. Now there are real buttons with
.spawn(( /// hover/press feedback; the keyboard accelerators stay as optional
GameOverScreen, /// shortcuts displayed inside the buttons' caption chips.
Node { fn spawn_game_over_screen(
position_type: PositionType::Absolute, commands: &mut Commands,
left: Val::Percent(0.0), score: i32,
top: Val::Percent(0.0), font_res: Option<&FontResource>,
width: Val::Percent(100.0), ) {
height: Val::Percent(100.0), spawn_modal(
flex_direction: FlexDirection::Column, commands,
justify_content: JustifyContent::Center, GameOverScreen,
align_items: AlignItems::Center, ui_theme::Z_MODAL_PANEL,
row_gap: Val::Px(20.0), |card| {
..default() spawn_modal_header(card, "No more moves available", font_res);
}, spawn_modal_body_text(
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.6)), card,
ZIndex(200), format!("Final score: {score}"),
)) ui_theme::TEXT_PRIMARY,
.with_children(|root| { font_res,
root.spawn(( );
Node { spawn_modal_actions(card, |actions| {
flex_direction: FlexDirection::Column, spawn_modal_button(
padding: UiRect::all(Val::Px(40.0)), actions,
row_gap: Val::Px(16.0), GameOverUndoButton,
min_width: Val::Px(340.0), "Undo",
align_items: AlignItems::Center, Some("U"),
border_radius: BorderRadius::all(Val::Px(12.0)), ButtonVariant::Secondary,
..default() font_res,
}, );
BackgroundColor(Color::srgb(0.10, 0.08, 0.08)), spawn_modal_button(
)) actions,
.with_children(|card| { GameOverNewGameButton,
// Header — explains why the overlay appeared. "New Game",
card.spawn(( Some("N"),
Text::new("No more moves available"), ButtonVariant::Primary,
TextFont { font_size: 36.0, ..default() }, font_res,
TextColor(Color::srgb(1.0, 0.4, 0.1)), );
));
// Score
card.spawn((
Text::new(format!("Score: {score}")),
TextFont { font_size: 24.0, ..default() },
TextColor(Color::WHITE),
));
// Action hints — stacked vertically for legibility.
card.spawn((
Node {
flex_direction: FlexDirection::Column,
row_gap: Val::Px(8.0),
margin: UiRect::top(Val::Px(8.0)),
align_items: AlignItems::Center,
..default()
},
))
.with_children(|hints| {
hints.spawn((
Text::new("Press N or Escape for a new game"),
TextFont { font_size: 20.0, ..default() },
TextColor(Color::srgb(0.3, 1.0, 0.4)),
));
hints.spawn((
Text::new("Press G to forfeit (counts as a loss)"),
TextFont { font_size: 20.0, ..default() },
TextColor(Color::srgb(1.0, 0.6, 0.2)),
));
});
}); });
}); },
);
} }
/// Handles keyboard input while `GameOverScreen` is open. /// Handles keyboard input while `GameOverScreen` is open.
@@ -687,6 +670,33 @@ fn handle_game_over_input(
} }
} }
/// Mouse / touch counterpart to `handle_game_over_input`. Click on the
/// modal's Undo button → fire `UndoRequestEvent` and despawn so
/// `check_no_moves` can re-evaluate. Click on New Game → fire
/// `NewGameRequestEvent` (the abandon-current-game guard does not apply
/// here because the game is unwinnable).
#[allow(clippy::type_complexity)]
fn handle_game_over_button_input(
mut commands: Commands,
new_game_buttons: Query<&Interaction, (With<GameOverNewGameButton>, Changed<Interaction>)>,
undo_buttons: Query<&Interaction, (With<GameOverUndoButton>, Changed<Interaction>)>,
screens: Query<Entity, With<GameOverScreen>>,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut undo: MessageWriter<UndoRequestEvent>,
) {
if screens.is_empty() {
return;
}
if new_game_buttons.iter().any(|i| *i == Interaction::Pressed) {
new_game.write(NewGameRequestEvent::default());
} else if undo_buttons.iter().any(|i| *i == Interaction::Pressed) {
for entity in &screens {
commands.entity(entity).despawn();
}
undo.write(UndoRequestEvent);
}
}
const AUTO_SAVE_INTERVAL_SECS: f32 = 30.0; const AUTO_SAVE_INTERVAL_SECS: f32 = 30.0;
/// Accumulated real-world seconds since the last auto-save. Exposed as a /// Accumulated real-world seconds since the last auto-save. Exposed as a
@@ -1294,13 +1304,24 @@ mod tests {
texts.iter().any(|t| t == "No more moves available"), texts.iter().any(|t| t == "No more moves available"),
"header must read 'No more moves available'; found: {texts:?}" "header must read 'No more moves available'; found: {texts:?}"
); );
// The modal now uses real buttons instead of plain action-hint
// text, so we assert on the button labels and their hotkey
// chips rather than the prior "Press N…" / "Press G…" prose.
assert!( assert!(
texts.iter().any(|t| t == "Press N or Escape for a new game"), texts.iter().any(|t| t == "New Game"),
"hint 1 must read 'Press N or Escape for a new game'; found: {texts:?}" "primary action button must label 'New Game'; found: {texts:?}"
); );
assert!( assert!(
texts.iter().any(|t| t == "Press G to forfeit (counts as a loss)"), texts.iter().any(|t| t == "N"),
"hint 2 must read 'Press G to forfeit (counts as a loss)'; found: {texts:?}" "primary action must show its 'N' hotkey chip; found: {texts:?}"
);
assert!(
texts.iter().any(|t| t == "Undo"),
"secondary action button must label 'Undo'; found: {texts:?}"
);
assert!(
texts.iter().any(|t| t == "U"),
"secondary action must show its 'U' hotkey chip; found: {texts:?}"
); );
} }