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:
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user