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_button_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>()
.add_systems(Update, tick_elapsed_time)
.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
/// when `has_legal_moves` returns true again (e.g. after undo) or when the
/// game is won.
#[allow(clippy::too_many_arguments)]
fn check_no_moves(
mut commands: Commands,
mut events: MessageReader<StateChangedEvent>,
@@ -544,6 +546,7 @@ fn check_no_moves(
mut toast: MessageWriter<InfoToastEvent>,
mut already_fired: Local<bool>,
game_over_screens: Query<Entity, With<GameOverScreen>>,
font_res: Option<Res<FontResource>>,
) {
// Reset the debounce flag on every state change so if something changes
// we re-evaluate on the next state change.
@@ -577,84 +580,64 @@ fn check_no_moves(
*already_fired = true;
// Only spawn the overlay if one does not already exist.
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
/// card layout remains visible behind the dialog.
fn spawn_game_over_screen(commands: &mut Commands, score: i32) {
commands
.spawn((
GameOverScreen,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(0.0),
top: Val::Percent(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
flex_direction: FlexDirection::Column,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
row_gap: Val::Px(20.0),
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.6)),
ZIndex(200),
))
.with_children(|root| {
root.spawn((
Node {
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(40.0)),
row_gap: Val::Px(16.0),
min_width: Val::Px(340.0),
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(12.0)),
..default()
},
BackgroundColor(Color::srgb(0.10, 0.08, 0.08)),
))
.with_children(|card| {
// Header — explains why the overlay appeared.
card.spawn((
Text::new("No more moves available"),
TextFont { font_size: 36.0, ..default() },
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)),
));
});
/// Replaces a bespoke layout that listed action hints as plain text
/// ("Press N for a new game", "Press G to forfeit") — the audit
/// flagged this as the same class of "feels like a debug panel"
/// problem the confirm modal had. Now there are real buttons with
/// hover/press feedback; the keyboard accelerators stay as optional
/// shortcuts displayed inside the buttons' caption chips.
fn spawn_game_over_screen(
commands: &mut Commands,
score: i32,
font_res: Option<&FontResource>,
) {
spawn_modal(
commands,
GameOverScreen,
ui_theme::Z_MODAL_PANEL,
|card| {
spawn_modal_header(card, "No more moves available", font_res);
spawn_modal_body_text(
card,
format!("Final score: {score}"),
ui_theme::TEXT_PRIMARY,
font_res,
);
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
GameOverUndoButton,
"Undo",
Some("U"),
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button(
actions,
GameOverNewGameButton,
"New Game",
Some("N"),
ButtonVariant::Primary,
font_res,
);
});
});
},
);
}
/// 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;
/// 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"),
"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!(
texts.iter().any(|t| t == "Press N or Escape for a new game"),
"hint 1 must read 'Press N or Escape for a new game'; found: {texts:?}"
texts.iter().any(|t| t == "New Game"),
"primary action button must label 'New Game'; found: {texts:?}"
);
assert!(
texts.iter().any(|t| t == "Press G to forfeit (counts as a loss)"),
"hint 2 must read 'Press G to forfeit (counts as a loss)'; found: {texts:?}"
texts.iter().any(|t| t == "N"),
"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:?}"
);
}