d4448bf0cd
Phase C of docs/ui-redesign-2026-07.md. The Modes row is gone — Home owns mode selection, so the popover's Play section carries a Home row firing the new ToggleHomeRequestEvent (read by toggle_home_screen alongside the existing M accelerator). Section headers are quiet caption-size labels inside the existing panel widget, not a new widget. The action-bar Modes button and its popover are untouched (their removal is Phase B territory when Home gains hierarchy). - MenuOption: Modes variant replaced by Home; rows grouped Play (Home) · You (Profile, Stats, Achievements) · Community (Leaderboard) · System (Settings, Help) - handle_menu_option_click no longer chains into spawn_modes_popover - Tests: tooltip sweep updated (7 rows still), new toggle_home_event_opens_home_screen covers the popover's open path Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
645 lines
24 KiB
Rust
645 lines
24 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|