feat(engine): convert ConfirmNewGameScreen to real-button modal
CI / Test & Lint (push) Failing after 21s
CI / Release Build (push) Has been skipped

Phase 3 step 4a of the UX overhaul. Closes the player's #2 smoke-test
complaint head-on: the abandon-current-game prompt previously rendered
"Yes (Y)" and "No (N)" as plain `Text` entities — not real `Button`s.
Clicks did nothing, hover/press feedback was absent, and the only path
through the modal was the keyboard.

Replace the bespoke 60-line spawn function with a 30-line call to the
ui_modal primitive:

- spawn_modal(ConfirmNewGameScreen, Z_MODAL_PANEL, ...) — uniform
  scrim + centred card with header / body / actions slots.
- Header: "Abandon current game?" (TYPE_HEADLINE, TEXT_PRIMARY).
- Body: "Your progress will be lost." (TYPE_BODY_LG, TEXT_SECONDARY).
- Actions row:
    Cancel (Secondary variant, hotkey "Esc") — left
    Yes, abandon (Primary yellow CTA, hotkey "Y") — right

The ConfirmNewGameScreen marker rides on the scrim entity per
ui_modal's contract; OriginalNewGameRequest is attached to the same
entity after spawn so handle_confirm_input / handle_confirm_button_input
can read it.

A new handle_confirm_button_input system mirrors the keyboard handler
for clicks: it queries `Changed<Interaction>` on `ConfirmYesButton` /
`ConfirmNoButton` and dispatches the same despawn + new-game-fire
logic. Keyboard accelerators (Y/Enter, N/Esc) still work; both paths
reach the same code through the existing `confirmed: true` flag on
NewGameRequestEvent (62cd1cf).

UiModalPlugin's paint_modal_buttons system (8da62bd) handles
hover/press recolouring automatically; no per-modal paint logic
needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-04-30 00:51:28 +00:00
parent 8da62bd05f
commit 3f922ede28
+105 -67
View File
@@ -18,7 +18,13 @@ use crate::events::{
CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent, MoveRequestEvent, CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent, MoveRequestEvent,
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
}; };
use crate::font_plugin::FontResource;
use crate::resources::{DragState, GameStateResource, SyncStatusResource}; use crate::resources::{DragState, GameStateResource, SyncStatusResource};
use crate::ui_modal::{
spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
spawn_modal_header, ButtonVariant,
};
use crate::ui_theme;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Task #57 — Confirm-new-game dialog // Task #57 — Confirm-new-game dialog
@@ -94,6 +100,7 @@ impl Plugin for GamePlugin {
) )
.add_systems(Update, check_no_moves.after(GameMutation)) .add_systems(Update, check_no_moves.after(GameMutation))
.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_game_over_input.after(GameMutation)) .add_systems(Update, handle_game_over_input.after(GameMutation))
.init_resource::<AutoSaveTimer>() .init_resource::<AutoSaveTimer>()
.add_systems(Update, tick_elapsed_time) .add_systems(Update, tick_elapsed_time)
@@ -157,6 +164,7 @@ fn handle_new_game(
mut changed: MessageWriter<StateChangedEvent>, mut changed: MessageWriter<StateChangedEvent>,
settings: Option<Res<crate::settings_plugin::SettingsResource>>, settings: Option<Res<crate::settings_plugin::SettingsResource>>,
path: Option<Res<GameStatePath>>, path: Option<Res<GameStatePath>>,
font_res: Option<Res<FontResource>>,
confirm_screens: Query<Entity, With<ConfirmNewGameScreen>>, confirm_screens: Query<Entity, With<ConfirmNewGameScreen>>,
game_over_screens: Query<Entity, With<GameOverScreen>>, game_over_screens: Query<Entity, With<GameOverScreen>>,
) { ) {
@@ -174,7 +182,7 @@ fn handle_new_game(
for entity in &game_over_screens { for entity in &game_over_screens {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} }
spawn_confirm_dialog(&mut commands, *ev); spawn_confirm_dialog(&mut commands, *ev, font_res.as_deref());
continue; continue;
} }
@@ -205,75 +213,70 @@ fn handle_new_game(
} }
} }
/// Spawns the confirm-new-game modal overlay. /// Marker on the primary "Yes, abandon" button inside the confirm modal.
#[derive(Component, Debug)]
pub struct ConfirmYesButton;
/// Marker on the secondary "Cancel" button inside the confirm modal.
#[derive(Component, Debug)]
pub struct ConfirmNoButton;
/// Spawns the confirm-new-game modal using the standard `ui_modal`
/// primitive — uniform scrim, centred card, real buttons with hover /
/// press states.
/// ///
/// Shown when the player requests a new game while moves have been made and /// Shown when the player requests a new game while moves have been made
/// the game is not yet won. The overlay stores the original request so the /// and the game is not yet won. The original `NewGameRequestEvent` is
/// `handle_confirm_input` system can replay it on confirmation. /// stored on the scrim entity so `handle_confirm_input` /
fn spawn_confirm_dialog(commands: &mut Commands, original_request: NewGameRequestEvent) { /// `handle_confirm_button` can replay it with the same seed / mode on
commands /// confirmation.
.spawn(( ///
/// Replaces a bespoke layout that used plain `Text` labels for "Yes (Y)"
/// and "No (N)" — those were not real Button entities, so the player
/// had no hover / press feedback and the modal felt like a debug panel
/// (the user's smoke-test "#2 complaint").
fn spawn_confirm_dialog(
commands: &mut Commands,
original_request: NewGameRequestEvent,
font_res: Option<&FontResource>,
) {
let scrim = spawn_modal(
commands,
ConfirmNewGameScreen, ConfirmNewGameScreen,
// Store the request so we can replay it on confirmation. ui_theme::Z_MODAL_PANEL,
OriginalNewGameRequest(original_request), |card| {
Node { spawn_modal_header(card, "Abandon current game?", font_res);
position_type: PositionType::Absolute, spawn_modal_body_text(
left: Val::Percent(0.0), card,
top: Val::Percent(0.0), "Your progress will be lost.",
width: Val::Percent(100.0), ui_theme::TEXT_SECONDARY,
height: Val::Percent(100.0), font_res,
flex_direction: FlexDirection::Column, );
justify_content: JustifyContent::Center, spawn_modal_actions(card, |actions| {
align_items: AlignItems::Center, spawn_modal_button(
row_gap: Val::Px(20.0), actions,
..default() ConfirmNoButton,
"Cancel",
Some("Esc"),
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button(
actions,
ConfirmYesButton,
"Yes, abandon",
Some("Y"),
ButtonVariant::Primary,
font_res,
);
});
}, },
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, 0.70)), );
ZIndex(250), // Attach the original request to the scrim so handle_confirm_input
)) // and handle_confirm_button can read it on confirmation.
.with_children(|root| { commands
// Dialog card .entity(scrim)
root.spawn(( .insert(OriginalNewGameRequest(original_request));
Node {
flex_direction: FlexDirection::Column,
padding: UiRect::all(Val::Px(40.0)),
row_gap: Val::Px(20.0),
min_width: Val::Px(360.0),
align_items: AlignItems::Center,
border_radius: BorderRadius::all(Val::Px(12.0)),
..default()
},
BackgroundColor(Color::srgb(0.10, 0.12, 0.15)),
))
.with_children(|card| {
// Heading
card.spawn((
Text::new("Abandon current game?"),
TextFont { font_size: 30.0, ..default() },
TextColor(Color::WHITE),
));
// Button row
card.spawn((Node {
flex_direction: FlexDirection::Row,
column_gap: Val::Px(24.0),
..default()
},))
.with_children(|row| {
// Yes button
row.spawn((
Text::new("Yes (Y)"),
TextFont { font_size: 22.0, ..default() },
TextColor(Color::srgb(0.3, 1.0, 0.4)),
));
// No button
row.spawn((
Text::new("No (N)"),
TextFont { font_size: 22.0, ..default() },
TextColor(Color::srgb(1.0, 0.4, 0.4)),
));
});
});
});
} }
/// Carries the original `NewGameRequestEvent` on the confirm overlay so /// Carries the original `NewGameRequestEvent` on the confirm overlay so
@@ -318,6 +321,41 @@ fn handle_confirm_input(
} }
} }
/// Mouse / touch counterpart to `handle_confirm_input`. Reads
/// `Changed<Interaction>` on the modal's `ConfirmYesButton` /
/// `ConfirmNoButton` so the modal closes and (on confirm) starts a new
/// game whether the player uses the keyboard accelerator or clicks.
///
/// This is the system that closes the user's #2 smoke-test complaint:
/// previously the dialog had only `Text::new("Yes (Y)")` labels — not
/// real button entities — so clicks did nothing and the only path
/// through the modal was the keyboard.
#[allow(clippy::too_many_arguments)]
fn handle_confirm_button_input(
mut commands: Commands,
yes_buttons: Query<&Interaction, (With<ConfirmYesButton>, Changed<Interaction>)>,
no_buttons: Query<&Interaction, (With<ConfirmNoButton>, Changed<Interaction>)>,
screens: Query<(Entity, &OriginalNewGameRequest), With<ConfirmNewGameScreen>>,
mut new_game: MessageWriter<NewGameRequestEvent>,
) {
let Ok((entity, original)) = screens.single() else {
return;
};
let confirmed = yes_buttons.iter().any(|i| *i == Interaction::Pressed);
let cancelled = no_buttons.iter().any(|i| *i == Interaction::Pressed);
if confirmed {
commands.entity(entity).despawn();
new_game.write(NewGameRequestEvent {
seed: original.0.seed,
mode: original.0.mode,
confirmed: true,
});
} else if cancelled {
commands.entity(entity).despawn();
}
}
fn handle_draw( fn handle_draw(
mut draws: MessageReader<DrawRequestEvent>, mut draws: MessageReader<DrawRequestEvent>,
mut game: ResMut<GameStateResource>, mut game: ResMut<GameStateResource>,