Merge pull request 'feat(engine): hold-/ hotkey cheat sheet + unified binding table (Phase J)' (#183) from feat/keyboard-completeness into master
This commit was merged in pull request #183.
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
//! Hold-`/` hotkey cheat sheet (Phase J).
|
||||
//!
|
||||
//! While `/` is held on a keyboard platform, a lightweight overlay
|
||||
//! lists every binding from [`crate::hotkeys::HOTKEYS`] — the same
|
||||
//! table the onboarding slide teaches from, so the two can never
|
||||
//! disagree. Releasing the key hides it instantly; it never captures
|
||||
//! input, never pauses the game, and never spawns while a modal owns
|
||||
//! the screen (which also keeps it out of the seed-entry text field's
|
||||
//! way).
|
||||
//!
|
||||
//! Not a `spawn_modal` modal on purpose: modals are sticky and guarded;
|
||||
//! this is a momentary reference card, closer to a tooltip than a
|
||||
//! dialog.
|
||||
|
||||
use bevy::input::ButtonInput;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::hotkeys::HOTKEYS;
|
||||
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
|
||||
use crate::ui_modal::ModalScrim;
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, HighContrastBorder, RADIUS_MD, TEXT_PRIMARY,
|
||||
TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
||||
Z_TOOLTIP,
|
||||
};
|
||||
|
||||
/// Marker on the cheat-sheet overlay root.
|
||||
#[derive(Component, Debug)]
|
||||
pub struct CheatSheetOverlay;
|
||||
|
||||
/// Registers the hold-`/` driver. Inert on touch-first builds.
|
||||
pub struct CheatSheetPlugin;
|
||||
|
||||
impl Plugin for CheatSheetPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<ButtonInput<KeyCode>>()
|
||||
.add_systems(Update, drive_cheat_sheet);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the overlay while `/` is held (and no modal owns the screen);
|
||||
/// hides it the frame the key releases.
|
||||
fn drive_cheat_sheet(
|
||||
keys: Res<ButtonInput<KeyCode>>,
|
||||
scrims: Query<(), With<ModalScrim>>,
|
||||
existing: Query<Entity, With<CheatSheetOverlay>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if !SHOW_KEYBOARD_ACCELERATORS {
|
||||
return;
|
||||
}
|
||||
let held = keys.pressed(KeyCode::Slash);
|
||||
if held && existing.is_empty() && scrims.is_empty() {
|
||||
spawn_cheat_sheet(&mut commands, font_res.as_deref());
|
||||
} else if !held {
|
||||
for entity in &existing {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_cheat_sheet(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_header = TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
};
|
||||
let font_keys = TextFont {
|
||||
font: font_handle.clone(),
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
let font_desc = TextFont {
|
||||
font: font_handle,
|
||||
font_size: TYPE_CAPTION,
|
||||
..default()
|
||||
};
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
CheatSheetOverlay,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
right: Val::Px(16.0),
|
||||
top: Val::Percent(8.0),
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_1,
|
||||
padding: UiRect::all(VAL_SPACE_4),
|
||||
border: UiRect::all(Val::Px(1.0)),
|
||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||
max_height: Val::Percent(84.0),
|
||||
overflow: Overflow::scroll_y(),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BG_ELEVATED),
|
||||
BorderColor::all(BORDER_STRONG),
|
||||
HighContrastBorder::with_default(BORDER_STRONG),
|
||||
GlobalZIndex(Z_TOOLTIP),
|
||||
))
|
||||
.with_children(|panel| {
|
||||
panel.spawn((
|
||||
Text::new("Keyboard shortcuts"),
|
||||
font_header.clone(),
|
||||
TextColor(TEXT_PRIMARY),
|
||||
Node {
|
||||
margin: UiRect::bottom(VAL_SPACE_2),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
for row in HOTKEYS {
|
||||
panel
|
||||
.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
column_gap: VAL_SPACE_3,
|
||||
..default()
|
||||
})
|
||||
.with_children(|line| {
|
||||
line.spawn((
|
||||
Text::new(row.keys),
|
||||
font_keys.clone(),
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
Node {
|
||||
min_width: Val::Px(110.0),
|
||||
..default()
|
||||
},
|
||||
));
|
||||
line.spawn((
|
||||
Text::new(row.description),
|
||||
font_desc.clone(),
|
||||
TextColor(TEXT_SECONDARY),
|
||||
));
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(CheatSheetPlugin);
|
||||
app.update();
|
||||
app
|
||||
}
|
||||
|
||||
fn overlay_count(app: &mut App) -> usize {
|
||||
app.world_mut()
|
||||
.query::<&CheatSheetOverlay>()
|
||||
.iter(app.world())
|
||||
.count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn holding_slash_shows_and_release_hides() {
|
||||
let mut app = app();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::Slash);
|
||||
app.update();
|
||||
assert_eq!(overlay_count(&mut app), 1, "held / must show the sheet");
|
||||
// Still held on later frames: exactly one overlay, no stacking.
|
||||
app.update();
|
||||
assert_eq!(overlay_count(&mut app), 1);
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.release(KeyCode::Slash);
|
||||
app.update();
|
||||
app.update();
|
||||
assert_eq!(overlay_count(&mut app), 0, "release must hide the sheet");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suppressed_while_a_modal_is_open() {
|
||||
let mut app = app();
|
||||
app.world_mut().spawn(ModalScrim);
|
||||
app.update();
|
||||
app.world_mut()
|
||||
.resource_mut::<ButtonInput<KeyCode>>()
|
||||
.press(KeyCode::Slash);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
overlay_count(&mut app),
|
||||
0,
|
||||
"the sheet must not spawn over a modal (or into a text field)"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,14 +14,14 @@ use crate::platform::{
|
||||
};
|
||||
use crate::{
|
||||
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
|
||||
CardAnimationPlugin, CardPlugin, ChallengePlugin, ContextualTipsPlugin, CursorPlugin,
|
||||
DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin,
|
||||
GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin,
|
||||
PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin,
|
||||
ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin,
|
||||
SolutionPlaybackPlugin, SplashPlugin, StatsPlugin, SyncProvider, TablePlugin, ThemePlugin,
|
||||
ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin,
|
||||
UiTooltipPlugin, WeeklyGoalsPlugin, WhatsNewPlugin, WinSummaryPlugin,
|
||||
CardAnimationPlugin, CardPlugin, ChallengePlugin, CheatSheetPlugin, ContextualTipsPlugin,
|
||||
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
||||
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin,
|
||||
PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
|
||||
ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SelectionPlugin,
|
||||
SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin, StatsPlugin, SyncProvider, TablePlugin,
|
||||
ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, UiFocusPlugin,
|
||||
UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WhatsNewPlugin, WinSummaryPlugin,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use crate::{
|
||||
@@ -117,6 +117,7 @@ impl Plugin for CoreGamePlugin {
|
||||
.add_plugins(OnboardingPlugin)
|
||||
.add_plugins(WhatsNewPlugin)
|
||||
.add_plugins(ContextualTipsPlugin)
|
||||
.add_plugins(CheatSheetPlugin)
|
||||
.add_plugins(WinSummaryPlugin)
|
||||
.add_plugins(UiModalPlugin)
|
||||
.add_plugins(UiFocusPlugin)
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Single source of truth for the desktop keyboard bindings (Phase J).
|
||||
//!
|
||||
//! Two static hotkey tables had already diverged (onboarding's slide
|
||||
//! and Help's controls reference); every future drift multiplies. This
|
||||
//! module owns THE table: the onboarding slide renders the
|
||||
//! [`HotkeyRow::essential`] subset, the hold-`/` cheat sheet
|
||||
//! ([`crate::cheat_sheet_plugin`]) renders everything.
|
||||
//!
|
||||
//! The table is hand-maintained but **pinned by test** against the
|
||||
//! handlers that actually consume each key — adding a binding without
|
||||
//! updating this table (or vice versa) is designed to fail review, not
|
||||
//! runtime. A registry generated from the input systems themselves is
|
||||
//! the eventual ideal; this is the honest 90 % at 1 % of the cost.
|
||||
|
||||
/// One row of the hotkey table.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct HotkeyRow {
|
||||
/// Display form of the key(s), e.g. `"D / Space"`.
|
||||
pub keys: &'static str,
|
||||
/// One-line action description.
|
||||
pub description: &'static str,
|
||||
/// `true` for the beginner-relevant subset the onboarding slide
|
||||
/// shows; the cheat sheet always shows every row.
|
||||
pub essential: bool,
|
||||
}
|
||||
|
||||
/// Every desktop keyboard binding, in teaching order.
|
||||
pub const HOTKEYS: &[HotkeyRow] = &[
|
||||
HotkeyRow {
|
||||
keys: "D / Space",
|
||||
description: "Draw from stock",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "U",
|
||||
description: "Undo last move (hold to repeat)",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "H",
|
||||
description: "Hint (repeat to cycle alternatives)",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "Tab → Enter",
|
||||
description: "Pick a card; arrows pick where; Enter to drop",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "N",
|
||||
description: "New Classic game",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "M",
|
||||
description: "Open Home (then 1–6 to pick a mode)",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "Esc",
|
||||
description: "Pause / resume; close the top dialog",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "F1",
|
||||
description: "Help / controls",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "S",
|
||||
description: "Stats & progression",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "A",
|
||||
description: "Achievements",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "O",
|
||||
description: "Settings",
|
||||
essential: true,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "P",
|
||||
description: "Profile",
|
||||
essential: false,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "L",
|
||||
description: "Leaderboard",
|
||||
essential: false,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "C",
|
||||
description: "Daily Challenge",
|
||||
essential: false,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "Z",
|
||||
description: "Zen mode",
|
||||
essential: false,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "X",
|
||||
description: "Challenge mode",
|
||||
essential: false,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "T",
|
||||
description: "Time Attack",
|
||||
essential: false,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "G",
|
||||
description: "Give up the current deal",
|
||||
essential: false,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "[ / ]",
|
||||
description: "Volume down / up",
|
||||
essential: false,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "F11",
|
||||
description: "Toggle fullscreen",
|
||||
essential: false,
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "/ (hold)",
|
||||
description: "This cheat sheet",
|
||||
essential: false,
|
||||
},
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn table_is_populated_and_well_formed() {
|
||||
assert!(!HOTKEYS.is_empty());
|
||||
for row in HOTKEYS {
|
||||
assert!(!row.keys.trim().is_empty(), "empty keys cell");
|
||||
assert!(
|
||||
!row.description.trim().is_empty(),
|
||||
"empty description for {}",
|
||||
row.keys
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn essential_subset_is_a_teachable_size() {
|
||||
let essentials = HOTKEYS.iter().filter(|r| r.essential).count();
|
||||
assert!(
|
||||
(6..=12).contains(&essentials),
|
||||
"the onboarding slide wants a skimmable essential set, got {essentials}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_cells_are_unique() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for row in HOTKEYS {
|
||||
assert!(seen.insert(row.keys), "duplicate hotkey row: {}", row.keys);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ pub mod avatar_plugin;
|
||||
pub mod card_animation;
|
||||
pub mod card_plugin;
|
||||
pub mod challenge_plugin;
|
||||
pub mod cheat_sheet_plugin;
|
||||
pub mod contextual_tips_plugin;
|
||||
pub mod core_game_plugin;
|
||||
pub mod cursor_plugin;
|
||||
@@ -27,6 +28,7 @@ pub mod font_plugin;
|
||||
pub mod game_plugin;
|
||||
pub mod help_plugin;
|
||||
pub mod home_plugin;
|
||||
pub mod hotkeys;
|
||||
pub mod hud_plugin;
|
||||
pub mod input_plugin;
|
||||
pub mod layout;
|
||||
@@ -95,6 +97,7 @@ pub use card_plugin::{
|
||||
pub use challenge_plugin::{
|
||||
CHALLENGE_UNLOCK_LEVEL, ChallengeAdvancedEvent, ChallengePlugin, challenge_progress_label,
|
||||
};
|
||||
pub use cheat_sheet_plugin::{CheatSheetOverlay, CheatSheetPlugin};
|
||||
pub use contextual_tips_plugin::ContextualTipsPlugin;
|
||||
pub use core_game_plugin::CoreGamePlugin;
|
||||
pub use cursor_plugin::CursorPlugin;
|
||||
|
||||
@@ -84,66 +84,11 @@ struct OnboardingSkipButton;
|
||||
pub struct OnboardingSlideIndex(pub u8);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Slide data — hotkey rows are taken verbatim from `help_plugin.rs` so the
|
||||
// two screens stay in sync without a shared abstraction.
|
||||
// Slide data — the hotkey slide renders the essential subset of the
|
||||
// shared table in `crate::hotkeys` (Phase J unified the previously
|
||||
// diverging copies here and in help_plugin).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single `key — description` pair shown on slide 3.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
struct HotkeyRow {
|
||||
keys: &'static str,
|
||||
description: &'static str,
|
||||
}
|
||||
|
||||
/// Most-used shortcuts from the `help_plugin` canonical list.
|
||||
///
|
||||
/// Updating the list in `help_plugin.rs` should be mirrored here. The
|
||||
/// ARCHITECTURE.md decision log calls out that we copy values rather than
|
||||
/// refactor the help plugin.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const HOTKEYS: &[HotkeyRow] = &[
|
||||
HotkeyRow {
|
||||
keys: "D / Space",
|
||||
description: "Draw from stock",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "U",
|
||||
description: "Undo last move",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "Tab → Enter",
|
||||
description: "Pick a card; arrows pick where; Enter to drop",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "N",
|
||||
description: "New Classic game",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "M",
|
||||
description: "Open Mode Launcher (then 1–5 to pick)",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "S",
|
||||
description: "Stats & progression",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "A",
|
||||
description: "Achievements",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "O",
|
||||
description: "Settings",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "Esc",
|
||||
description: "Pause / resume",
|
||||
},
|
||||
HotkeyRow {
|
||||
keys: "F1",
|
||||
description: "Help / controls",
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -446,8 +391,10 @@ fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>)
|
||||
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
||||
spawn_modal_header(card, "Keyboard shortcuts", font_res);
|
||||
|
||||
// Vertical list of `key — description` rows, same chip style as HelpScreen.
|
||||
for row in HOTKEYS {
|
||||
// Vertical list of `key — description` rows, same chip style as
|
||||
// HelpScreen. Essential subset only — the full table lives on
|
||||
// the hold-`/` cheat sheet.
|
||||
for row in crate::hotkeys::HOTKEYS.iter().filter(|r| r.essential) {
|
||||
card.spawn(Node {
|
||||
flex_direction: FlexDirection::Row,
|
||||
align_items: AlignItems::Center,
|
||||
@@ -826,22 +773,15 @@ mod tests {
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Hotkey list is non-empty (guards against accidental truncation)
|
||||
// Hotkey slide renders a non-empty essential subset (the table's own
|
||||
// integrity tests live in `crate::hotkeys`)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn hotkey_list_is_non_empty() {
|
||||
assert!(!HOTKEYS.is_empty(), "HOTKEYS must not be empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_hotkey_rows_have_non_empty_fields() {
|
||||
for row in HOTKEYS {
|
||||
assert!(!row.keys.is_empty(), "hotkey key field must not be empty");
|
||||
assert!(
|
||||
!row.description.is_empty(),
|
||||
"hotkey description must not be empty"
|
||||
);
|
||||
}
|
||||
fn hotkey_slide_subset_is_non_empty() {
|
||||
assert!(
|
||||
crate::hotkeys::HOTKEYS.iter().any(|r| r.essential),
|
||||
"the onboarding slide needs at least one essential hotkey"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user