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
+106 -68
View File
@@ -18,7 +18,13 @@ use crate::events::{
CardFlippedEvent, DrawRequestEvent, GameWonEvent, InfoToastEvent, MoveRequestEvent,
NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
};
use crate::font_plugin::FontResource;
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
@@ -94,6 +100,7 @@ impl Plugin for GamePlugin {
)
.add_systems(Update, check_no_moves.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))
.init_resource::<AutoSaveTimer>()
.add_systems(Update, tick_elapsed_time)
@@ -157,6 +164,7 @@ fn handle_new_game(
mut changed: MessageWriter<StateChangedEvent>,
settings: Option<Res<crate::settings_plugin::SettingsResource>>,
path: Option<Res<GameStatePath>>,
font_res: Option<Res<FontResource>>,
confirm_screens: Query<Entity, With<ConfirmNewGameScreen>>,
game_over_screens: Query<Entity, With<GameOverScreen>>,
) {
@@ -174,7 +182,7 @@ fn handle_new_game(
for entity in &game_over_screens {
commands.entity(entity).despawn();
}
spawn_confirm_dialog(&mut commands, *ev);
spawn_confirm_dialog(&mut commands, *ev, font_res.as_deref());
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
/// the game is not yet won. The overlay stores the original request so the
/// `handle_confirm_input` system can replay it on confirmation.
fn spawn_confirm_dialog(commands: &mut Commands, original_request: NewGameRequestEvent) {
commands
.spawn((
ConfirmNewGameScreen,
// Store the request so we can replay it on confirmation.
OriginalNewGameRequest(original_request),
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.70)),
ZIndex(250),
))
.with_children(|root| {
// Dialog card
root.spawn((
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)),
));
});
/// Shown when the player requests a new game while moves have been made
/// and the game is not yet won. The original `NewGameRequestEvent` is
/// stored on the scrim entity so `handle_confirm_input` /
/// `handle_confirm_button` can replay it with the same seed / mode on
/// confirmation.
///
/// 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,
ui_theme::Z_MODAL_PANEL,
|card| {
spawn_modal_header(card, "Abandon current game?", font_res);
spawn_modal_body_text(
card,
"Your progress will be lost.",
ui_theme::TEXT_SECONDARY,
font_res,
);
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
ConfirmNoButton,
"Cancel",
Some("Esc"),
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button(
actions,
ConfirmYesButton,
"Yes, abandon",
Some("Y"),
ButtonVariant::Primary,
font_res,
);
});
});
},
);
// Attach the original request to the scrim so handle_confirm_input
// and handle_confirm_button can read it on confirmation.
commands
.entity(scrim)
.insert(OriginalNewGameRequest(original_request));
}
/// 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(
mut draws: MessageReader<DrawRequestEvent>,
mut game: ResMut<GameStateResource>,