feat(engine): convert ConfirmNewGameScreen to real-button modal
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:
@@ -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>,
|
||||||
|
|||||||
Reference in New Issue
Block a user