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