fix(engine): Home tile glyphs render + modal fits any viewport

Two regressions Quat caught in screenshot review of the picture-tile
rework:

1. Tofu boxes for 4 of 5 tiles. The earlier emoji picks (calendar,
   cherry-blossom, lightning, stopwatch) live in Unicode planes that
   most Linux desktop fonts don't cover, so they rendered as
   missing-glyph rectangles. Swapped to BMP / Dingbats codepoints
   that the system-default font fallback always has:
   - Daily: \u{2605} (BLACK STAR)
   - Zen:   \u{2740} (WHITE FLORETTE)
   - Challenge: \u{2726} (BLACK FOUR-POINTED STAR)
   - TimeAttack: \u{231A} (WATCH, Misc Symbols / Unicode 1.1)
   Classic keeps its club (\u{2663}) — already rendered correctly.

2. Cancel button pushed off the bottom of the viewport. The 3-row
   tile grid alone is ~540 px; on the 800x600 minimum window the
   modal exceeded the screen. Wrapped chips + draw row + grid in a
   `HomeScrollable` Node with `max_height: 70vh` and `Overflow::scroll_y()`,
   adding a `scroll_home_panel` system to drive `ScrollPosition` from
   `MouseWheel`. Mirrors the existing Settings / Leaderboard /
   Achievements scrollable pattern. Cancel sits outside the scroll
   so it's always reachable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-06 16:52:44 +00:00
parent 9fe650fa20
commit 40d6e0ab17
+100 -38
View File
@@ -13,6 +13,7 @@
//! [`InfoToastEvent`] explaining the gate but does not launch the mode //! [`InfoToastEvent`] explaining the gate but does not launch the mode
//! or close the overlay. //! or close the overlay.
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::input::ButtonInput; use bevy::input::ButtonInput;
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::game_state::DrawMode; use solitaire_core::game_state::DrawMode;
@@ -72,6 +73,14 @@ struct HomeDrawOneButton;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct HomeDrawThreeButton; struct HomeDrawThreeButton;
/// Marker on the scrollable inner Node containing the player chips,
/// draw-mode row, and tile grid. Wrapping these in a scrollable
/// container keeps the modal usable on small viewports — without it,
/// the 3-row tile stack pushes the Cancel button off the bottom of
/// the screen on 800x600 hardware. Mirrors `SettingsPanelScrollable`.
#[derive(Component, Debug)]
struct HomeScrollable;
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Private mode-card data shape // Private mode-card data shape
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -116,20 +125,24 @@ impl HomeMode {
/// for real per-mode artwork — chosen for one-glyph-tells-the-mode /// for real per-mode artwork — chosen for one-glyph-tells-the-mode
/// readability rather than visual fidelity. Swap to `Image` nodes /// readability rather than visual fidelity. Swap to `Image` nodes
/// when art lands; the rest of the tile layout doesn't change. /// when art lands; the rest of the tile layout doesn't change.
///
/// Picks are deliberately constrained to BMP / Dingbats codepoints
/// (Unicode 1.x) so the project's system-default font fallback
/// renders every glyph. Earlier emoji choices (📅 🌸 ⚡ ⏱) showed
/// up as missing-glyph rectangles on Linux desktops without an
/// emoji font in the system font stack.
fn glyph(self) -> &'static str { fn glyph(self) -> &'static str {
match self { match self {
// Black club is the densest card-suit glyph at small sizes. // Black club card suit, every font has it.
HomeMode::Classic => "\u{2663}", HomeMode::Classic => "\u{2663}",
// Calendar emoji — matches the date callout below. // Black star — Dingbats; reads as "today's special".
HomeMode::Daily => "\u{1F4C5}", HomeMode::Daily => "\u{2605}",
// Lotus flower stands in for the lotus-position emoji // White florette — Dingbats; reads as a calm bloom for Zen.
// because the latter renders inconsistently across HomeMode::Zen => "\u{2740}",
// platforms; the flower is a single codepoint. // Black four-pointed star — Dingbats; reads as a hard target.
HomeMode::Zen => "\u{1F338}", HomeMode::Challenge => "\u{2726}",
// High-voltage / lightning bolt for the hardest mode. // Watch face — Misc Symbols (Unicode 1.1), pre-emoji vintage.
HomeMode::Challenge => "\u{26A1}", HomeMode::TimeAttack => "\u{231A}",
// Stopwatch matches the timer concept of Time Attack.
HomeMode::TimeAttack => "\u{23F1}",
} }
} }
@@ -221,6 +234,9 @@ impl Plugin for HomePlugin {
.add_message::<InfoToastEvent>() .add_message::<InfoToastEvent>()
.add_message::<ToggleProfileRequestEvent>() .add_message::<ToggleProfileRequestEvent>()
.add_message::<SettingsChangedEvent>() .add_message::<SettingsChangedEvent>()
// Defensively register MouseWheel so `scroll_home_panel`
// runs cleanly under MinimalPlugins headless tests too.
.add_message::<MouseWheel>()
// `.chain()` because several systems (M-toggle, card click, // `.chain()` because several systems (M-toggle, card click,
// cancel button, digit-key shortcut) all read the // cancel button, digit-key shortcut) all read the
// `HomeScreen` entity and may queue a despawn on it in the // `HomeScreen` entity and may queue a despawn on it in the
@@ -242,7 +258,8 @@ impl Plugin for HomePlugin {
handle_home_digit_keys, handle_home_digit_keys,
) )
.chain(), .chain(),
); )
.add_systems(Update, scroll_home_panel);
} }
} }
@@ -469,6 +486,33 @@ fn handle_home_cancel_button(
// Header chip + draw-mode button handlers // Header chip + draw-mode button handlers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Routes mouse-wheel events into the Home modal's scrollable body
/// while the modal is open. No-op when no `HomeScrollable` exists in
/// the world (modal closed). Mirrors `scroll_settings_panel` and
/// `scroll_leaderboard_panel`.
fn scroll_home_panel(
mut scroll_evr: MessageReader<MouseWheel>,
mut scrollables: Query<&mut ScrollPosition, With<HomeScrollable>>,
) {
if scrollables.is_empty() {
scroll_evr.clear();
return;
}
let delta_y: f32 = scroll_evr
.read()
.map(|ev| match ev.unit {
MouseScrollUnit::Line => ev.y * 50.0,
MouseScrollUnit::Pixel => ev.y,
})
.sum();
if delta_y == 0.0 {
return;
}
for mut sp in scrollables.iter_mut() {
sp.0.y = (sp.0.y - delta_y).max(0.0);
}
}
/// Click on the player-stats header chip → fire /// Click on the player-stats header chip → fire
/// [`ToggleProfileRequestEvent`] so the Profile overlay opens on top /// [`ToggleProfileRequestEvent`] so the Profile overlay opens on top
/// of Home. Closing Profile (`P` / `Esc`) returns the player to the /// of Home. Closing Profile (`P` / `Esc`) returns the player to the
@@ -686,33 +730,51 @@ fn spawn_home_screen(commands: &mut Commands, ctx: HomeContext<'_>) {
let scrim = spawn_modal(commands, HomeScreen, Z_MODAL_PANEL, |card| { let scrim = spawn_modal(commands, HomeScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Choose a Mode", font_res); spawn_modal_header(card, "Choose a Mode", font_res);
spawn_home_header_chips(card, &ctx); // Scrollable middle — chips + draw row + tile grid. Constrained
spawn_draw_mode_row(card, &ctx); // to 70vh so the modal fits on small viewports (the 5-tile
// grid alone is ~540 px). Cancel button sits outside this
// node so it's always one click away.
card.spawn((
HomeScrollable,
ScrollPosition::default(),
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_3,
width: Val::Percent(100.0),
max_height: Val::Vh(70.0),
overflow: Overflow::scroll_y(),
..default()
},
))
.with_children(|body| {
spawn_home_header_chips(body, &ctx);
spawn_draw_mode_row(body, &ctx);
// Mode tiles in a wrapping 2-column grid. Each tile takes 48% // Mode tiles in a wrapping 2-column grid. Each tile takes 48%
// of the row so column_gap fits comfortably; the 5 modes wrap // of the row so column_gap fits comfortably; the 5 modes wrap
// to a third row of one tile, which we leave left-aligned — // to a third row of one tile, which we leave left-aligned —
// the asymmetry matches MSSC's "Daily Challenges / Today's // the asymmetry matches MSSC's "Daily Challenges / Today's
// Event" half-cell on the right of their grid and keeps the // Event" half-cell on the right of their grid and keeps the
// visual rhythm. // visual rhythm.
card.spawn(Node { body.spawn(Node {
flex_direction: FlexDirection::Row, flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap, flex_wrap: FlexWrap::Wrap,
row_gap: VAL_SPACE_3, row_gap: VAL_SPACE_3,
column_gap: VAL_SPACE_3, column_gap: VAL_SPACE_3,
width: Val::Percent(100.0), width: Val::Percent(100.0),
..default() ..default()
}) })
.with_children(|grid| { .with_children(|grid| {
for mode in [ for mode in [
HomeMode::Classic, HomeMode::Classic,
HomeMode::Daily, HomeMode::Daily,
HomeMode::Zen, HomeMode::Zen,
HomeMode::Challenge, HomeMode::Challenge,
HomeMode::TimeAttack, HomeMode::TimeAttack,
] { ] {
spawn_mode_card(grid, mode, &ctx); spawn_mode_card(grid, mode, &ctx);
} }
});
}); });
spawn_modal_actions(card, |actions| { spawn_modal_actions(card, |actions| {