fa54c58bc4
Touch layout (USE_TOUCH_UI_LAYOUT) restructures the bottom action bar to five buttons: an enlarged Undo / Draw / Hint trio (96x64px targets, 1.35x labels) between compact Menu and Pause. Draw is new — it fires the same DrawRequestEvent as tapping the stock, so the most frequent action no longer needs a reach to the top of a tall folded screen. Help, Modes, and New Game leave the touch bar (they live in Menu -> System, the Home grid, and Home's hero respectively). Desktop keeps the seven-button bar unchanged (decision 5: touch-only). Holding Undo now steps back repeatedly after a 0.45s delay (5.5/s), each step through the normal request path so the scoring penalty applies. New self-ambiguous DrawRequestWriters set keeps the ambiguity gate at zero with the fourth DrawRequestEvent writer. 6 new hud_plugin tests; workspace suite + clippy green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
725 lines
27 KiB
Rust
725 lines
27 KiB
Rust
//! HUD interaction: action-button handlers, Modes/Menu popovers, and
|
|
//! the chrome tap-to-toggle gesture.
|
|
|
|
use super::*;
|
|
|
|
/// `Changed<Interaction>` filter ensures we only react on the frame the
|
|
/// interaction state transitions, avoiding repeat events while the button
|
|
/// is held down. Each click handler fires the corresponding request event,
|
|
/// which `pause_plugin` / `help_plugin` / `game_plugin` consume alongside
|
|
/// their existing keyboard handlers.
|
|
pub(super) fn handle_new_game_button(
|
|
interaction_query: Query<&Interaction, (With<NewGameButton>, Changed<Interaction>)>,
|
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
|
) {
|
|
for interaction in &interaction_query {
|
|
if *interaction == Interaction::Pressed {
|
|
new_game.write(NewGameRequestEvent::default());
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn handle_undo_button(
|
|
interaction_query: Query<&Interaction, (With<UndoButton>, Changed<Interaction>)>,
|
|
mut undo: MessageWriter<UndoRequestEvent>,
|
|
) {
|
|
for interaction in &interaction_query {
|
|
if *interaction == Interaction::Pressed {
|
|
undo.write(UndoRequestEvent);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Seconds the Undo button must be continuously held before hold-to-repeat
|
|
/// kicks in. Long enough that a normal tap (press + release inside one or
|
|
/// two frames) never triggers a second undo.
|
|
const UNDO_HOLD_INITIAL_DELAY_SECS: f32 = 0.45;
|
|
|
|
/// Interval between repeated undos while the hold continues. ~5.5 undos/s —
|
|
/// fast enough to unwind a long line, slow enough to release in time when
|
|
/// the board reaches the state the player wants.
|
|
const UNDO_HOLD_REPEAT_INTERVAL_SECS: f32 = 0.18;
|
|
|
|
/// Hold-to-repeat undo (Phase F): while the Undo button stays pressed,
|
|
/// fire additional [`UndoRequestEvent`]s after an initial delay, one per
|
|
/// repeat interval. The plain tap path stays in [`handle_undo_button`]
|
|
/// (its `Changed<Interaction>` filter fires exactly once per press);
|
|
/// this system only adds events once the hold outlives the delay, so a
|
|
/// tap never double-undoes.
|
|
///
|
|
/// Each repeat goes through the normal request queue — the scoring
|
|
/// penalty and No-Undo-mode gating in the consumer apply to every step.
|
|
pub(super) fn repeat_undo_on_hold(
|
|
time: Res<Time>,
|
|
buttons: Query<&Interaction, With<UndoButton>>,
|
|
mut state: ResMut<UndoHoldState>,
|
|
mut undo: MessageWriter<UndoRequestEvent>,
|
|
) {
|
|
let held = buttons.iter().any(|i| *i == Interaction::Pressed);
|
|
if !held {
|
|
if state.held_secs != 0.0 {
|
|
state.held_secs = 0.0;
|
|
}
|
|
return;
|
|
}
|
|
|
|
let before = state.held_secs;
|
|
state.held_secs += time.delta_secs();
|
|
if undo_hold_crossed_fire_boundary(before, state.held_secs) {
|
|
undo.write(UndoRequestEvent);
|
|
}
|
|
}
|
|
|
|
/// `true` when a hold that lasted `before` seconds at the previous frame
|
|
/// and `now` seconds this frame should fire a repeat undo: once when the
|
|
/// hold first outlives the initial delay, then once per repeat-interval
|
|
/// boundary. Pure so the timing contract is unit-testable without
|
|
/// fighting `Time`.
|
|
pub(super) fn undo_hold_crossed_fire_boundary(before: f32, now: f32) -> bool {
|
|
if now < UNDO_HOLD_INITIAL_DELAY_SECS {
|
|
return false;
|
|
}
|
|
if before < UNDO_HOLD_INITIAL_DELAY_SECS {
|
|
// Crossed the initial-delay threshold this frame — first repeat.
|
|
return true;
|
|
}
|
|
// Fire once each time the hold crosses another repeat-interval boundary.
|
|
let intervals_before =
|
|
((before - UNDO_HOLD_INITIAL_DELAY_SECS) / UNDO_HOLD_REPEAT_INTERVAL_SECS).floor();
|
|
let intervals_now =
|
|
((now - UNDO_HOLD_INITIAL_DELAY_SECS) / UNDO_HOLD_REPEAT_INTERVAL_SECS).floor();
|
|
intervals_now > intervals_before
|
|
}
|
|
|
|
/// Click on the touch action bar's Draw button — same
|
|
/// [`DrawRequestEvent`] the stock-pile tap writes, so the consumer's
|
|
/// rules (recycle, won-game rejection) apply identically. Skipped while
|
|
/// paused, mirroring `handle_stock_click`'s guard.
|
|
pub(super) fn handle_draw_button(
|
|
interaction_query: Query<&Interaction, (With<DrawButton>, Changed<Interaction>)>,
|
|
paused: Option<Res<PausedResource>>,
|
|
mut draw: MessageWriter<DrawRequestEvent>,
|
|
) {
|
|
if paused.is_some_and(|p| p.0) {
|
|
return;
|
|
}
|
|
for interaction in &interaction_query {
|
|
if *interaction == Interaction::Pressed {
|
|
draw.write(DrawRequestEvent);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn handle_pause_button(
|
|
interaction_query: Query<&Interaction, (With<PauseButton>, Changed<Interaction>)>,
|
|
mut pause: MessageWriter<PauseRequestEvent>,
|
|
) {
|
|
for interaction in &interaction_query {
|
|
if *interaction == Interaction::Pressed {
|
|
pause.write(PauseRequestEvent);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn handle_help_button(
|
|
interaction_query: Query<&Interaction, (With<HelpButton>, Changed<Interaction>)>,
|
|
mut help: MessageWriter<HelpRequestEvent>,
|
|
) {
|
|
for interaction in &interaction_query {
|
|
if *interaction == Interaction::Pressed {
|
|
help.write(HelpRequestEvent);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn handle_hint_button(
|
|
interaction_query: Query<&Interaction, (With<HintButton>, Changed<Interaction>)>,
|
|
paused: Option<Res<PausedResource>>,
|
|
game: Option<Res<GameStateResource>>,
|
|
solver_config: Option<Res<crate::input_plugin::HintSolverConfig>>,
|
|
mut pending_hint: Option<ResMut<crate::pending_hint::PendingHintTask>>,
|
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
|
) {
|
|
for interaction in &interaction_query {
|
|
if *interaction != Interaction::Pressed {
|
|
continue;
|
|
}
|
|
if paused.as_ref().is_some_and(|p| p.0) {
|
|
return;
|
|
}
|
|
let Some(ref g) = game else { return };
|
|
if g.0.is_won() {
|
|
info_toast.write(InfoToastEvent(HINT_WON_MSG.to_string()));
|
|
return;
|
|
}
|
|
if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) {
|
|
hint.spawn(g.0.clone(), cfg.moves_budget, cfg.states_budget);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Toggles the [`ModesPopover`]: spawns it on first click, despawns it on
|
|
/// second click. Mode rows are populated per the player's current level so
|
|
/// only unlocked options appear.
|
|
pub(super) fn handle_modes_button(
|
|
interaction_query: Query<&Interaction, (With<ModesButton>, Changed<Interaction>)>,
|
|
popovers: Query<Entity, With<ModesPopover>>,
|
|
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
|
progress: Option<Res<ProgressResource>>,
|
|
daily: Option<Res<DailyChallengeResource>>,
|
|
font_res: Option<Res<FontResource>>,
|
|
mut commands: Commands,
|
|
) {
|
|
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
|
if !pressed {
|
|
return;
|
|
}
|
|
if let Ok(entity) = popovers.single() {
|
|
commands.entity(entity).despawn();
|
|
for e in &backdrops {
|
|
commands.entity(e).despawn();
|
|
}
|
|
} else {
|
|
spawn_modes_popover(
|
|
&mut commands,
|
|
progress.as_deref(),
|
|
daily.as_deref(),
|
|
font_res.as_deref(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Spawns the modes popover anchored just below the action bar's right
|
|
/// edge. Always includes Classic; includes Daily Challenge when a daily
|
|
/// resource is loaded; includes Zen / Challenge / Time Attack once the
|
|
/// player reaches the challenge unlock level.
|
|
pub(super) fn spawn_modes_popover(
|
|
commands: &mut Commands,
|
|
progress: Option<&ProgressResource>,
|
|
daily: Option<&DailyChallengeResource>,
|
|
font_res: Option<&FontResource>,
|
|
) {
|
|
let level = progress.map_or(0, |p| p.0.level);
|
|
let font = TextFont {
|
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
|
font_size: 15.0,
|
|
..default()
|
|
};
|
|
|
|
// Each row carries a tooltip alongside its label so hover reveals
|
|
// a one-line description of what the mode does — mirroring the
|
|
// tooltips on the action-bar buttons that opened this popover.
|
|
let mut rows: Vec<(ModeOption, &'static str, &'static str)> = vec![(
|
|
ModeOption::Classic,
|
|
"Classic",
|
|
"Standard Klondike. Score, timer, and full progression.",
|
|
)];
|
|
if daily.is_some() {
|
|
rows.push((
|
|
ModeOption::DailyChallenge,
|
|
"Daily Challenge",
|
|
"Today's seeded deal. Same for every player worldwide.",
|
|
));
|
|
}
|
|
if level >= CHALLENGE_UNLOCK_LEVEL {
|
|
rows.push((
|
|
ModeOption::Zen,
|
|
"Zen",
|
|
"No timer, no score, no penalties. Just play.",
|
|
));
|
|
rows.push((
|
|
ModeOption::Challenge,
|
|
"Challenge",
|
|
"Hand-picked hard seeds. No undo allowed.",
|
|
));
|
|
rows.push((
|
|
ModeOption::TimeAttack,
|
|
"Time Attack",
|
|
"Win as many games as you can in ten minutes.",
|
|
));
|
|
}
|
|
|
|
// Popover opens upward from just above the bottom action bar.
|
|
// Use a platform-aware offset that clears the bar height + safe-area
|
|
// gesture zone on Android, and the flat bar height on desktop.
|
|
let popover_bottom = Val::Px(ACTION_POPOVER_BOTTOM_PX);
|
|
|
|
commands
|
|
.spawn((
|
|
ModesPopover,
|
|
HudPopoverOpen,
|
|
Node {
|
|
position_type: PositionType::Absolute,
|
|
right: VAL_SPACE_3,
|
|
bottom: popover_bottom,
|
|
flex_direction: FlexDirection::Column,
|
|
row_gap: VAL_SPACE_1,
|
|
padding: UiRect::all(VAL_SPACE_2),
|
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
|
..default()
|
|
},
|
|
BackgroundColor(BG_ELEVATED),
|
|
ZIndex(Z_HUD_POPOVER),
|
|
))
|
|
.with_children(|panel| {
|
|
for (option, label, tooltip) in rows {
|
|
panel
|
|
.spawn((
|
|
option,
|
|
ActionButton,
|
|
PopoverRow,
|
|
Button,
|
|
Tooltip::new(tooltip),
|
|
Node {
|
|
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
|
|
justify_content: JustifyContent::FlexStart,
|
|
align_items: AlignItems::Center,
|
|
min_width: Val::Px(150.0),
|
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
|
..default()
|
|
},
|
|
BackgroundColor(ACTION_BTN_IDLE),
|
|
))
|
|
.with_children(|b| {
|
|
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
|
|
});
|
|
}
|
|
});
|
|
|
|
// Fullscreen transparent backdrop at Z_HUD_POPOVER_BACKDROP (below the
|
|
// popover at Z_HUD_POPOVER) so tapping outside light-dismisses it.
|
|
commands.spawn((
|
|
ModesPopoverBackdrop,
|
|
Button,
|
|
Node {
|
|
position_type: PositionType::Absolute,
|
|
left: Val::Px(0.0),
|
|
top: Val::Px(0.0),
|
|
width: Val::Percent(100.0),
|
|
height: Val::Percent(100.0),
|
|
..default()
|
|
},
|
|
BackgroundColor(Color::NONE),
|
|
ZIndex(Z_HUD_POPOVER_BACKDROP),
|
|
));
|
|
}
|
|
|
|
/// Dispatches the click on a popover row to the matching request event,
|
|
/// then despawns the popover.
|
|
///
|
|
/// Classic uses [`NewGameRequestEvent`] directly; the other modes use
|
|
/// their `Start*RequestEvent` so the existing keyboard handler runs
|
|
/// (level gates, `TimeAttackResource` setup, daily seed lookup, etc.) —
|
|
/// the popover stays a thin entry point and never duplicates that logic.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(super) fn handle_mode_option_click(
|
|
interaction_query: Query<(&Interaction, &ModeOption), Changed<Interaction>>,
|
|
popovers: Query<Entity, With<ModesPopover>>,
|
|
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
|
mut zen: MessageWriter<StartZenRequestEvent>,
|
|
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
|
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
|
|
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
|
|
mut commands: Commands,
|
|
) {
|
|
let mut clicked_any = false;
|
|
for (interaction, option) in &interaction_query {
|
|
if *interaction != Interaction::Pressed {
|
|
continue;
|
|
}
|
|
clicked_any = true;
|
|
match option {
|
|
ModeOption::Classic => {
|
|
new_game.write(NewGameRequestEvent::default());
|
|
}
|
|
ModeOption::DailyChallenge => {
|
|
daily.write(StartDailyChallengeRequestEvent);
|
|
}
|
|
ModeOption::Zen => {
|
|
zen.write(StartZenRequestEvent);
|
|
}
|
|
ModeOption::Challenge => {
|
|
challenge.write(StartChallengeRequestEvent);
|
|
}
|
|
ModeOption::TimeAttack => {
|
|
time_attack.write(StartTimeAttackRequestEvent);
|
|
}
|
|
}
|
|
}
|
|
if clicked_any && let Ok(entity) = popovers.single() {
|
|
commands.entity(entity).despawn();
|
|
for e in &backdrops {
|
|
commands.entity(e).despawn();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Toggles the [`MenuPopover`]: spawns it on first click, despawns it on
|
|
/// second click. The popover lists the five overlays previously only
|
|
/// reachable via the S / A / P / O / L hotkeys.
|
|
pub(super) fn handle_menu_button(
|
|
interaction_query: Query<&Interaction, (With<MenuButton>, Changed<Interaction>)>,
|
|
popovers: Query<Entity, With<MenuPopover>>,
|
|
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
|
scrims: Query<(), With<ModalScrim>>,
|
|
font_res: Option<Res<FontResource>>,
|
|
mut commands: Commands,
|
|
) {
|
|
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
|
if !pressed {
|
|
return;
|
|
}
|
|
if let Ok(entity) = popovers.single() {
|
|
commands.entity(entity).despawn();
|
|
for e in &backdrops {
|
|
commands.entity(e).despawn();
|
|
}
|
|
} else if scrims.is_empty() {
|
|
spawn_menu_popover(&mut commands, font_res.as_deref());
|
|
}
|
|
}
|
|
|
|
/// Spawns the menu popover anchored just below the action bar, with one
|
|
/// row per overlay. Each row dispatches its corresponding
|
|
/// `Toggle*RequestEvent` so the existing toggle handler runs (and the
|
|
/// HUD never duplicates spawn / despawn / fetch logic).
|
|
pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>) {
|
|
let font = TextFont {
|
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
|
font_size: 15.0,
|
|
..default()
|
|
};
|
|
|
|
// One popover row: destination, label, hover tooltip.
|
|
type MenuRow = (MenuOption, &'static str, &'static str);
|
|
// Destinations grouped into labelled sections (Phase C of the menu
|
|
// redesign): Play · You · Community · System. Each row carries a
|
|
// tooltip alongside its label so hover reveals a one-line
|
|
// description of what each overlay shows — mirroring the tooltips
|
|
// on the action-bar buttons that opened this popover. Mode
|
|
// selection lives on Home now, so there is no Modes row.
|
|
let sections: [(&'static str, &'static [MenuRow]); 4] = [
|
|
(
|
|
"Play",
|
|
&[(
|
|
MenuOption::Home,
|
|
"Home",
|
|
"Pick a mode, continue, or start a new game.",
|
|
)],
|
|
),
|
|
(
|
|
"You",
|
|
&[
|
|
(
|
|
MenuOption::Profile,
|
|
"Profile",
|
|
"Your level, XP progress, and sync status.",
|
|
),
|
|
(
|
|
MenuOption::Stats,
|
|
"Stats",
|
|
"Lifetime totals: wins, streaks, fastest time, best score.",
|
|
),
|
|
(
|
|
MenuOption::Achievements,
|
|
"Achievements",
|
|
"Browse unlocked achievements and the rewards still ahead.",
|
|
),
|
|
],
|
|
),
|
|
(
|
|
"Community",
|
|
&[(
|
|
MenuOption::Leaderboard,
|
|
"Leaderboard",
|
|
"Top players from your sync server. Opt in from Profile.",
|
|
)],
|
|
),
|
|
(
|
|
"System",
|
|
&[
|
|
(
|
|
MenuOption::Settings,
|
|
"Settings",
|
|
"Audio, animations, theme, draw mode, and sync.",
|
|
),
|
|
(
|
|
MenuOption::Help,
|
|
"Help",
|
|
"Show controls, rules, and keyboard shortcuts.",
|
|
),
|
|
],
|
|
),
|
|
];
|
|
|
|
// Same upward-opening placement as ModesPopover.
|
|
let popover_bottom = Val::Px(ACTION_POPOVER_BOTTOM_PX);
|
|
|
|
commands
|
|
.spawn((
|
|
MenuPopover,
|
|
HudPopoverOpen,
|
|
Node {
|
|
position_type: PositionType::Absolute,
|
|
right: VAL_SPACE_3,
|
|
bottom: popover_bottom,
|
|
flex_direction: FlexDirection::Column,
|
|
row_gap: VAL_SPACE_1,
|
|
padding: UiRect::all(VAL_SPACE_2),
|
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
|
..default()
|
|
},
|
|
BackgroundColor(BG_ELEVATED),
|
|
ZIndex(Z_HUD_POPOVER),
|
|
))
|
|
.with_children(|panel| {
|
|
let section_font = TextFont {
|
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
|
font_size: TYPE_CAPTION,
|
|
..default()
|
|
};
|
|
for (section, rows) in sections {
|
|
// Non-interactive section header — a quiet divider
|
|
// inside the existing panel, not a new widget.
|
|
panel
|
|
.spawn(Node {
|
|
padding: UiRect::axes(VAL_SPACE_3, Val::Px(2.0)),
|
|
..default()
|
|
})
|
|
.with_children(|b| {
|
|
b.spawn((
|
|
Text::new(section),
|
|
section_font.clone(),
|
|
TextColor(TEXT_SECONDARY),
|
|
));
|
|
});
|
|
for &(option, label, tooltip) in rows {
|
|
panel
|
|
.spawn((
|
|
option,
|
|
ActionButton,
|
|
PopoverRow,
|
|
Button,
|
|
Tooltip::new(tooltip),
|
|
Node {
|
|
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
|
|
justify_content: JustifyContent::FlexStart,
|
|
align_items: AlignItems::Center,
|
|
min_width: Val::Px(150.0),
|
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
|
..default()
|
|
},
|
|
BackgroundColor(ACTION_BTN_IDLE),
|
|
))
|
|
.with_children(|b| {
|
|
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// Transparent fullscreen backdrop behind the popover — tapping anywhere
|
|
// outside the panel light-dismisses it via handle_menu_backdrop_click.
|
|
commands.spawn((
|
|
MenuPopoverBackdrop,
|
|
Button,
|
|
Node {
|
|
position_type: PositionType::Absolute,
|
|
left: Val::Px(0.0),
|
|
top: Val::Px(0.0),
|
|
width: Val::Percent(100.0),
|
|
height: Val::Percent(100.0),
|
|
..default()
|
|
},
|
|
BackgroundColor(Color::NONE),
|
|
ZIndex(Z_HUD_POPOVER_BACKDROP),
|
|
));
|
|
}
|
|
|
|
/// Dispatches the click on a menu row to the matching toggle event,
|
|
/// then despawns the popover.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(super) fn handle_menu_option_click(
|
|
interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
|
|
popovers: Query<Entity, With<MenuPopover>>,
|
|
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
|
mut home: MessageWriter<ToggleHomeRequestEvent>,
|
|
mut stats: MessageWriter<ToggleStatsRequestEvent>,
|
|
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
|
|
mut profile: MessageWriter<ToggleProfileRequestEvent>,
|
|
mut settings: MessageWriter<ToggleSettingsRequestEvent>,
|
|
mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>,
|
|
mut help: MessageWriter<HelpRequestEvent>,
|
|
mut commands: Commands,
|
|
) {
|
|
let mut clicked_any = false;
|
|
for (interaction, option) in &interaction_query {
|
|
if *interaction != Interaction::Pressed {
|
|
continue;
|
|
}
|
|
clicked_any = true;
|
|
match option {
|
|
MenuOption::Home => {
|
|
home.write(ToggleHomeRequestEvent);
|
|
}
|
|
MenuOption::Help => {
|
|
help.write(HelpRequestEvent);
|
|
}
|
|
MenuOption::Stats => {
|
|
stats.write(ToggleStatsRequestEvent);
|
|
}
|
|
MenuOption::Achievements => {
|
|
achievements.write(ToggleAchievementsRequestEvent);
|
|
}
|
|
MenuOption::Profile => {
|
|
profile.write(ToggleProfileRequestEvent);
|
|
}
|
|
MenuOption::Settings => {
|
|
settings.write(ToggleSettingsRequestEvent);
|
|
}
|
|
MenuOption::Leaderboard => {
|
|
leaderboard.write(ToggleLeaderboardRequestEvent);
|
|
}
|
|
}
|
|
}
|
|
if clicked_any && let Ok(entity) = popovers.single() {
|
|
commands.entity(entity).despawn();
|
|
for e in &backdrops {
|
|
commands.entity(e).despawn();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back
|
|
/// is pressed while the popover is open. Runs so `PausePlugin`'s guard (which
|
|
/// checks [`HudPopoverOpen`]) sees an empty world and stays idle.
|
|
pub(super) fn close_modes_popover_on_escape(
|
|
keys: Res<ButtonInput<KeyCode>>,
|
|
popovers: Query<Entity, With<ModesPopover>>,
|
|
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
|
mut commands: Commands,
|
|
) {
|
|
if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() {
|
|
return;
|
|
}
|
|
for e in popovers.iter().chain(backdrops.iter()) {
|
|
commands.entity(e).despawn();
|
|
}
|
|
}
|
|
|
|
/// Despawns the [`MenuPopover`] and its backdrop when Escape / Android back
|
|
/// is pressed while the popover is open.
|
|
pub(super) fn close_menu_popover_on_escape(
|
|
keys: Res<ButtonInput<KeyCode>>,
|
|
popovers: Query<Entity, With<MenuPopover>>,
|
|
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
|
mut commands: Commands,
|
|
) {
|
|
if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() {
|
|
return;
|
|
}
|
|
for e in popovers.iter().chain(backdrops.iter()) {
|
|
commands.entity(e).despawn();
|
|
}
|
|
}
|
|
|
|
/// Despawns the [`ModesPopover`] and its backdrop when the player taps
|
|
/// anywhere outside the panel.
|
|
pub(super) fn handle_modes_backdrop_click(
|
|
interaction_query: Query<&Interaction, (With<ModesPopoverBackdrop>, Changed<Interaction>)>,
|
|
popovers: Query<Entity, With<ModesPopover>>,
|
|
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
|
mut commands: Commands,
|
|
) {
|
|
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
|
if !pressed {
|
|
return;
|
|
}
|
|
for e in popovers.iter().chain(backdrops.iter()) {
|
|
commands.entity(e).despawn();
|
|
}
|
|
}
|
|
|
|
/// Despawns the [`MenuPopover`] and its backdrop when the player taps
|
|
/// anywhere outside the panel (i.e. the transparent backdrop is pressed).
|
|
pub(super) fn handle_menu_backdrop_click(
|
|
interaction_query: Query<&Interaction, (With<MenuPopoverBackdrop>, Changed<Interaction>)>,
|
|
popovers: Query<Entity, With<MenuPopover>>,
|
|
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
|
mut commands: Commands,
|
|
) {
|
|
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
|
if !pressed {
|
|
return;
|
|
}
|
|
for e in popovers.iter().chain(backdrops.iter()) {
|
|
commands.entity(e).despawn();
|
|
}
|
|
}
|
|
|
|
#[cfg(target_os = "android")]
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(super) fn toggle_hud_on_tap(
|
|
mut touch_events: MessageReader<TouchInput>,
|
|
drag: Res<DragState>,
|
|
scrims: Query<(), With<ModalScrim>>,
|
|
paused: Option<Res<PausedResource>>,
|
|
mut tracker: ResMut<HudTapTracker>,
|
|
mut hud_vis: ResMut<HudVisibility>,
|
|
buttons: Query<&Interaction, With<ActionButton>>,
|
|
mut game_consumed: ResMut<GameInputConsumedResource>,
|
|
) {
|
|
use bevy::input::touch::TouchPhase;
|
|
if !scrims.is_empty() || paused.is_some_and(|p| p.0) {
|
|
// Drain buffered events so they don't replay in the frame after
|
|
// the scrim despawns, which would trigger a spurious visibility
|
|
// toggle as the resume/close button tap's Started+Ended pair
|
|
// replays in the now-scrim-free frame.
|
|
for _ in touch_events.read() {}
|
|
tracker.start_pos = None;
|
|
tracker.started_on_button = false;
|
|
game_consumed.0 = false;
|
|
return;
|
|
}
|
|
for event in touch_events.read() {
|
|
match event.phase {
|
|
TouchPhase::Started => {
|
|
tracker.start_pos = Some(event.position);
|
|
// Record whether the finger-down landed on a button so
|
|
// the finger-up doesn't double-fire (toggle bar + press
|
|
// button at the same time).
|
|
tracker.started_on_button = buttons.iter().any(|i| *i != Interaction::None);
|
|
}
|
|
TouchPhase::Ended if drag.is_idle() => {
|
|
// Also treat taps where game logic consumed the touch (e.g.
|
|
// drawing from stock) as "on button" so they don't toggle
|
|
// the HUD. The flag is set on TouchPhase::Started by the
|
|
// input system that consumed the tap and must be cleared here
|
|
// regardless of whether we toggle.
|
|
let on_button = tracker.started_on_button || game_consumed.0;
|
|
game_consumed.0 = false;
|
|
if let Some(start) = tracker.start_pos.take()
|
|
&& !on_button
|
|
&& (event.position - start).length() < HUD_TAP_SLOP_PX
|
|
{
|
|
*hud_vis = match *hud_vis {
|
|
HudVisibility::Visible => HudVisibility::Hidden,
|
|
HudVisibility::Hidden => HudVisibility::Visible,
|
|
};
|
|
}
|
|
tracker.started_on_button = false;
|
|
}
|
|
// Moved: don't clear start_pos — Android fires Moved for normal
|
|
// tap jitter, and the distance check at Ended already rejects
|
|
// real drags. Clearing here would silently swallow tap toggles.
|
|
TouchPhase::Canceled => {
|
|
tracker.start_pos = None;
|
|
tracker.started_on_button = false;
|
|
game_consumed.0 = false;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|