refactor(engine): audit and rationalize platform cfg gates (closes #49)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -14,9 +14,8 @@ use std::collections::{HashMap, HashSet};
|
|||||||
|
|
||||||
use bevy::color::Color;
|
use bevy::color::Color;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::WindowResized;
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
use bevy::sprite::Anchor;
|
use bevy::sprite::Anchor;
|
||||||
|
use bevy::window::WindowResized;
|
||||||
use solitaire_core::card::{Card, Rank, Suit};
|
use solitaire_core::card::{Card, Rank, Suit};
|
||||||
use solitaire_core::game_state::{DrawMode, GameState};
|
use solitaire_core::game_state::{DrawMode, GameState};
|
||||||
use solitaire_core::pile::PileType;
|
use solitaire_core::pile::PileType;
|
||||||
@@ -26,13 +25,14 @@ use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
|||||||
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration, CARD_ANIM_Z_LIFT};
|
use crate::animation_plugin::{CardAnim, EffectiveSlideDuration, CARD_ANIM_Z_LIFT};
|
||||||
use crate::card_animation::CardAnimation;
|
use crate::card_animation::CardAnimation;
|
||||||
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
|
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent, StateChangedEvent};
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::{Layout, LayoutResource, LayoutSystem};
|
use crate::layout::{Layout, LayoutResource, LayoutSystem};
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
|
use crate::platform::USE_TOUCH_UI_LAYOUT;
|
||||||
use crate::resources::{DragState, GameStateResource};
|
use crate::resources::{DragState, GameStateResource};
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
use crate::table_plugin::{PileMarker, PILE_MARKER_DEFAULT_COLOUR};
|
use crate::table_plugin::{PileMarker, PILE_MARKER_DEFAULT_COLOUR};
|
||||||
use crate::font_plugin::FontResource;
|
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
|
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
|
||||||
CARD_SHADOW_OFFSET_DRAG, CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_DRAG,
|
CARD_SHADOW_OFFSET_DRAG, CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_DRAG,
|
||||||
@@ -73,10 +73,9 @@ pub const STACK_FAN_FRAC: f32 = 0.025;
|
|||||||
/// Font size as a fraction of card width.
|
/// Font size as a fraction of card width.
|
||||||
const FONT_SIZE_FRAC: f32 = 0.28;
|
const FONT_SIZE_FRAC: f32 = 0.28;
|
||||||
|
|
||||||
/// Font-size fraction for the large-print readability overlay on Android.
|
/// Font-size fraction for the large-print readability overlay on touch HUD layouts.
|
||||||
/// Spawned on top of PNG face cards to make the rank+suit legible at phone
|
/// Spawned on top of PNG face cards to make the rank+suit legible at phone
|
||||||
/// scale, where the baked-in PNG corner text is only ~10 px physical.
|
/// scale, where the baked-in PNG corner text is only ~10 px physical.
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
const FONT_SIZE_FRAC_MOBILE: f32 = 0.35;
|
const FONT_SIZE_FRAC_MOBILE: f32 = 0.35;
|
||||||
|
|
||||||
/// Card-face background — Terminal `#1a1a1a` (BG_ELEVATED).
|
/// Card-face background — Terminal `#1a1a1a` (BG_ELEVATED).
|
||||||
@@ -177,13 +176,12 @@ pub struct CardEntity {
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct CardLabel;
|
pub struct CardLabel;
|
||||||
|
|
||||||
/// Marker for the large-print rank+suit corner overlay on Android.
|
/// Marker for the large-print rank+suit corner overlay used by touch HUD layouts.
|
||||||
///
|
///
|
||||||
/// Spawned on top of PNG face cards (face-up only) at font size
|
/// Spawned on top of PNG face cards (face-up only) at font size
|
||||||
/// [`FONT_SIZE_FRAC_MOBILE`] so the rank and suit character are
|
/// [`FONT_SIZE_FRAC_MOBILE`] so the rank and suit character are
|
||||||
/// readable at phone scale. Only exists when `CardImageSet` is present
|
/// readable at phone scale. Only exists when `CardImageSet` is present
|
||||||
/// (the fallback solid-colour path uses a plain `CardLabel` instead).
|
/// (the fallback solid-colour path uses a plain `CardLabel` instead).
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
#[derive(Component, Debug, Clone)]
|
#[derive(Component, Debug, Clone)]
|
||||||
struct AndroidCornerLabel(pub String);
|
struct AndroidCornerLabel(pub String);
|
||||||
|
|
||||||
@@ -192,10 +190,11 @@ struct AndroidCornerLabel(pub String);
|
|||||||
/// Covers the card art's own small corner rank/suit text so only the
|
/// Covers the card art's own small corner rank/suit text so only the
|
||||||
/// large overlay is visible. Sized at [`FONT_SIZE_FRAC_MOBILE`]-derived
|
/// large overlay is visible. Sized at [`FONT_SIZE_FRAC_MOBILE`]-derived
|
||||||
/// dimensions and coloured [`CARD_FACE_COLOUR`] to match the card face.
|
/// dimensions and coloured [`CARD_FACE_COLOUR`] to match the card face.
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
#[derive(Component, Debug, Clone, Copy)]
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
struct AndroidCornerBg;
|
struct AndroidCornerBg;
|
||||||
|
|
||||||
|
type AndroidCornerBgFilter = (With<AndroidCornerBg>, Without<AndroidCornerLabel>);
|
||||||
|
|
||||||
/// Marker component indicating the card is currently highlighted as a hint.
|
/// Marker component indicating the card is currently highlighted as a hint.
|
||||||
/// `remaining` counts down in real seconds; the highlight is removed when it
|
/// `remaining` counts down in real seconds; the highlight is removed when it
|
||||||
/// reaches zero and the card sprite colour is restored to its normal value.
|
/// reaches zero and the card sprite colour is restored to its normal value.
|
||||||
@@ -465,7 +464,6 @@ impl Plugin for CardPlugin {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
app.add_systems(Update, resize_android_corner_labels);
|
app.add_systems(Update, resize_android_corner_labels);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -920,15 +918,11 @@ fn spawn_card_entity(
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "android")]
|
if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
|
||||||
if card_images.is_some() {
|
|
||||||
entity.with_children(|b| {
|
entity.with_children(|b| {
|
||||||
add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast, font_handle);
|
add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast, font_handle);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Suppress unused-variable warning when not building for Android.
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
let _ = font_handle;
|
|
||||||
entity_id
|
entity_id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1013,15 +1007,11 @@ fn update_card_entity(
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
#[cfg(target_os = "android")]
|
if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
|
||||||
if card_images.is_some() {
|
|
||||||
commands.entity(entity).with_children(|b| {
|
commands.entity(entity).with_children(|b| {
|
||||||
add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast, font_handle);
|
add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast, font_handle);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Suppress unused-variable warning when not building for Android.
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
let _ = font_handle;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn label_for(card: &Card) -> String {
|
fn label_for(card: &Card) -> String {
|
||||||
@@ -1094,9 +1084,8 @@ fn label_visibility(card: &Card) -> Visibility {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rank+suit string for the Android readability overlay.
|
/// Rank+suit string for the readability overlay on touch HUD layouts.
|
||||||
/// Uses Unicode suit glyphs (♠♥♦♣ — U+2660–U+2666, covered by FiraMono).
|
/// Uses Unicode suit glyphs (♠♥♦♣ — U+2660–U+2666, covered by FiraMono).
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
fn mobile_label_for(card: &Card) -> String {
|
fn mobile_label_for(card: &Card) -> String {
|
||||||
let rank = match card.rank {
|
let rank = match card.rank {
|
||||||
Rank::Ace => "A",
|
Rank::Ace => "A",
|
||||||
@@ -1132,7 +1121,6 @@ fn mobile_label_for(card: &Card) -> String {
|
|||||||
/// those glyphs, causing a coloured missing-glyph rectangle to appear in
|
/// those glyphs, causing a coloured missing-glyph rectangle to appear in
|
||||||
/// the text colour — the root cause of the "red square on face-down cards"
|
/// the text colour — the root cause of the "red square on face-down cards"
|
||||||
/// visual bug (the box bleeds through near the card edge at z=0.02).
|
/// visual bug (the box bleeds through near the card edge at z=0.02).
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
fn add_android_corner_label(
|
fn add_android_corner_label(
|
||||||
parent: &mut ChildSpawnerCommands,
|
parent: &mut ChildSpawnerCommands,
|
||||||
card: &Card,
|
card: &Card,
|
||||||
@@ -2146,15 +2134,11 @@ fn resize_cards_in_place(
|
|||||||
/// change or any window resize). The full despawn/respawn path in
|
/// change or any window resize). The full despawn/respawn path in
|
||||||
/// `update_card_entity` already handles game-state changes; this system
|
/// `update_card_entity` already handles game-state changes; this system
|
||||||
/// covers the resize-only path where children are mutated in place.
|
/// covers the resize-only path where children are mutated in place.
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
fn resize_android_corner_labels(
|
fn resize_android_corner_labels(
|
||||||
layout: Res<LayoutResource>,
|
layout: Res<LayoutResource>,
|
||||||
card_images: Option<Res<CardImageSet>>,
|
card_images: Option<Res<CardImageSet>>,
|
||||||
mut text_query: Query<(&AndroidCornerLabel, &mut Text2d, &mut TextFont, &mut Transform)>,
|
mut text_query: Query<(&AndroidCornerLabel, &mut Text2d, &mut TextFont, &mut Transform)>,
|
||||||
mut bg_query: Query<
|
mut bg_query: Query<(&mut Sprite, &mut Transform), AndroidCornerBgFilter>,
|
||||||
(&mut Sprite, &mut Transform),
|
|
||||||
(With<AndroidCornerBg>, Without<AndroidCornerLabel>),
|
|
||||||
>,
|
|
||||||
) {
|
) {
|
||||||
if !layout.is_changed() || card_images.is_none() {
|
if !layout.is_changed() || card_images.is_none() {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -8,7 +8,10 @@ use std::sync::Mutex;
|
|||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
use crate::platform::{StorageBackendResource, default_storage_backend};
|
use crate::platform::{
|
||||||
|
ClipboardBackendResource, StorageBackendResource, default_clipboard_backend,
|
||||||
|
default_storage_backend,
|
||||||
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
AchievementPlugin, AnalyticsPlugin, AnimationPlugin, AssetSourcesPlugin, AudioPlugin,
|
AchievementPlugin, AnalyticsPlugin, AnimationPlugin, AssetSourcesPlugin, AudioPlugin,
|
||||||
AutoCompletePlugin, AvatarPlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
AutoCompletePlugin, AvatarPlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin,
|
||||||
@@ -53,6 +56,14 @@ impl Plugin for CoreGamePlugin {
|
|||||||
warn!("storage: failed to initialize platform backend: {err}");
|
warn!("storage: failed to initialize platform backend: {err}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
match default_clipboard_backend() {
|
||||||
|
Ok(clipboard) => {
|
||||||
|
app.insert_resource(ClipboardBackendResource(clipboard));
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
warn!("clipboard: failed to initialize platform backend: {err}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
app.add_plugins(AssetSourcesPlugin)
|
app.add_plugins(AssetSourcesPlugin)
|
||||||
.add_plugins(ThemePlugin)
|
.add_plugins(ThemePlugin)
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ use crate::events::{
|
|||||||
CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent,
|
CardFlippedEvent, DrawRequestEvent, FoundationCompletedEvent, GameWonEvent, InfoToastEvent,
|
||||||
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, UndoRequestEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
const NO_MOVES_MSG: &str = "No moves available — tap the stock to draw or start a new game";
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
const NO_MOVES_MSG: &str = "No moves available — press D to draw or N for a new game";
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
|
use crate::resources::{DragState, GameStateResource, SyncStatusResource};
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
@@ -1107,11 +1112,7 @@ fn check_no_moves(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !moves_ok && !*already_fired {
|
if !moves_ok && !*already_fired {
|
||||||
#[cfg(target_os = "android")]
|
toast.write(InfoToastEvent(NO_MOVES_MSG.to_string()));
|
||||||
let no_moves_msg = "No moves available \u{2014} tap the stock to draw or start a new game";
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
let no_moves_msg = "No moves available \u{2014} press D to draw or N for a new game";
|
|
||||||
toast.write(InfoToastEvent(no_moves_msg.to_string()));
|
|
||||||
*already_fired = true;
|
*already_fired = true;
|
||||||
// Only spawn the overlay if one does not already exist, and no other
|
// Only spawn the overlay if one does not already exist, and no other
|
||||||
// modal scrim is currently open (global ModalScrim guard).
|
// modal scrim is currently open (global ModalScrim guard).
|
||||||
|
|||||||
@@ -11,13 +11,15 @@ use crate::events::HelpRequestEvent;
|
|||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
use crate::hud_plugin::ANDROID_HINT_LABEL;
|
use crate::hud_plugin::ANDROID_HINT_LABEL;
|
||||||
|
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||||
ModalScrim, ScrimDismissible,
|
ModalScrim, ScrimDismissible,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL};
|
use crate::ui_theme::{
|
||||||
#[cfg(not(target_os = "android"))]
|
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, SPACE_2, TEXT_PRIMARY, TEXT_SECONDARY,
|
||||||
use crate::ui_theme::{BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TYPE_CAPTION, VAL_SPACE_1};
|
TYPE_BODY, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL,
|
||||||
|
};
|
||||||
|
|
||||||
/// Marker on the help overlay root node.
|
/// Marker on the help overlay root node.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
@@ -246,7 +248,6 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
|
|||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
let font_row = font_section.clone();
|
let font_row = font_section.clone();
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
let font_kbd = TextFont {
|
let font_kbd = TextFont {
|
||||||
font: font_handle,
|
font: font_handle,
|
||||||
font_size: TYPE_CAPTION,
|
font_size: TYPE_CAPTION,
|
||||||
@@ -291,29 +292,30 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
|
|||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
.with_children(|line| {
|
.with_children(|line| {
|
||||||
// Keyboard chip — suppressed on Android (no keyboard).
|
// Keyboard chip — suppressed on touch-first Android builds.
|
||||||
#[cfg(not(target_os = "android"))]
|
if SHOW_KEYBOARD_ACCELERATORS {
|
||||||
line.spawn((
|
line.spawn((
|
||||||
Node {
|
Node {
|
||||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
||||||
min_width: Val::Px(64.0),
|
min_width: Val::Px(64.0),
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BorderColor::all(BORDER_SUBTLE),
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||||
))
|
))
|
||||||
.with_children(|chip| {
|
.with_children(|chip| {
|
||||||
chip.spawn((
|
chip.spawn((
|
||||||
Text::new(row.keys),
|
Text::new(row.keys),
|
||||||
font_kbd.clone(),
|
font_kbd.clone(),
|
||||||
TextColor(TEXT_PRIMARY),
|
TextColor(TEXT_PRIMARY),
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
line.spawn((
|
}
|
||||||
Text::new(row.description),
|
|
||||||
|
line.spawn(( Text::new(row.description),
|
||||||
font_row.clone(),
|
font_row.clone(),
|
||||||
TextColor(TEXT_PRIMARY),
|
TextColor(TEXT_PRIMARY),
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -174,17 +174,17 @@ impl HomeMode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The keyboard accelerator that dispatches the same launch event,
|
/// The keyboard accelerator that dispatches the same launch event,
|
||||||
/// shown in a small chip on the card.
|
/// shown in a small chip on desktop cards.
|
||||||
#[cfg(not(target_os = "android"))]
|
fn hotkey(self) -> Option<&'static str> {
|
||||||
fn hotkey(self) -> &'static str {
|
let key = match self {
|
||||||
match self {
|
|
||||||
HomeMode::Classic => "N",
|
HomeMode::Classic => "N",
|
||||||
HomeMode::Daily => "C",
|
HomeMode::Daily => "C",
|
||||||
HomeMode::Zen => "Z",
|
HomeMode::Zen => "Z",
|
||||||
HomeMode::Challenge => "X",
|
HomeMode::Challenge => "X",
|
||||||
HomeMode::TimeAttack => "T",
|
HomeMode::TimeAttack => "T",
|
||||||
HomeMode::PlayBySeed => "6",
|
HomeMode::PlayBySeed => "6",
|
||||||
}
|
};
|
||||||
|
crate::platform::SHOW_KEYBOARD_ACCELERATORS.then_some(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `true` when the mode is gated behind `CHALLENGE_UNLOCK_LEVEL`.
|
/// `true` when the mode is gated behind `CHALLENGE_UNLOCK_LEVEL`.
|
||||||
@@ -1392,27 +1392,28 @@ fn spawn_mode_card(
|
|||||||
));
|
));
|
||||||
|
|
||||||
if unlocked {
|
if unlocked {
|
||||||
// Hotkey chip — suppressed on Android (touch builds have no keyboard).
|
// Hotkey chip — suppressed on touch-first Android builds.
|
||||||
#[cfg(not(target_os = "android"))]
|
if let Some(hotkey) = mode.hotkey() {
|
||||||
row.spawn((
|
row.spawn((
|
||||||
Node {
|
Node {
|
||||||
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
|
||||||
min_width: Val::Px(32.0),
|
min_width: Val::Px(32.0),
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
BorderColor::all(BORDER_SUBTLE),
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||||
))
|
))
|
||||||
.with_children(|chip| {
|
.with_children(|chip| {
|
||||||
chip.spawn((
|
chip.spawn((
|
||||||
Text::new(mode.hotkey().to_string()),
|
Text::new(hotkey),
|
||||||
font_chip.clone(),
|
font_chip.clone(),
|
||||||
TextColor(TEXT_SECONDARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Lock icon stand-in — text glyph keeps the layout
|
// Lock icon stand-in — text glyph keeps the layout
|
||||||
// dependency-free (no asset loader required) and
|
// dependency-free (no asset loader required) and
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ use crate::game_plugin::GameMutation;
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
use crate::input_plugin::TouchDragSet;
|
use crate::input_plugin::TouchDragSet;
|
||||||
use crate::layout::LayoutSystem;
|
use crate::layout::LayoutSystem;
|
||||||
|
use crate::platform::{SHOW_KEYBOARD_ACCELERATORS, USE_TOUCH_UI_LAYOUT};
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
@@ -140,9 +141,8 @@ pub struct HudColumn;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct HudActionBar;
|
pub struct HudActionBar;
|
||||||
|
|
||||||
/// Marker on the text node inside each action-bar button (Android only).
|
/// Marker on the text node inside each touch-layout action-bar button.
|
||||||
/// Used by `resize_action_bar_labels` to update font size on window resize.
|
/// Used by `resize_action_bar_labels` to update font size on window resize.
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct ActionButtonLabel;
|
struct ActionButtonLabel;
|
||||||
|
|
||||||
@@ -309,6 +309,23 @@ pub struct HintButton;
|
|||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub(crate) const ANDROID_HINT_LABEL: &str = "!";
|
pub(crate) const ANDROID_HINT_LABEL: &str = "!";
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
const ACTION_BAR_LABELS: [&str; 7] = ["\u{2261}", "\u{2190}", "||", "?", ANDROID_HINT_LABEL, "M", "+"];
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
const ACTION_BAR_LABELS: [&str; 7] = ["Menu \u{2193}", "Undo", "Pause", "Help", "Hint", "Modes \u{2193}", "New Game"];
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0);
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
const ACTION_BAR_COLUMN_GAP: Val = VAL_SPACE_2;
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
const ACTION_POPOVER_BOTTOM_PX: f32 = 200.0;
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
const ACTION_POPOVER_BOTTOM_PX: f32 = 80.0;
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
const HINT_WON_MSG: &str = "Game won! Tap New Game to play again";
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
const HINT_WON_MSG: &str = "Game won! Press N for a new game";
|
||||||
|
|
||||||
/// Marker on the "Modes" action button. Click toggles the [`ModesPopover`]
|
/// Marker on the "Modes" action button. Click toggles the [`ModesPopover`]
|
||||||
/// (a small dropdown panel) below the action bar. Each popover row starts
|
/// (a small dropdown panel) below the action bar. Each popover row starts
|
||||||
/// the corresponding game mode.
|
/// the corresponding game mode.
|
||||||
@@ -857,53 +874,13 @@ fn spawn_action_buttons(
|
|||||||
windows: Query<&Window>,
|
windows: Query<&Window>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
// On Android the glyph labels must scale with the viewport so they remain
|
let action_font_size = action_bar_font_size(windows.iter().next().map_or(900.0, |win| win.width()));
|
||||||
// legible on any screen density. Use the window width at startup; the
|
|
||||||
// resize_action_bar_labels system keeps this current on window changes.
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
let action_font_size = {
|
|
||||||
let w = windows.iter().next().map_or(900.0, |win| win.width());
|
|
||||||
action_bar_font_size(w)
|
|
||||||
};
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
let action_font_size = TYPE_BODY;
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
let _windows = windows;
|
|
||||||
|
|
||||||
let font = TextFont {
|
let font = TextFont {
|
||||||
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
font_size: action_font_size,
|
font_size: action_font_size,
|
||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
|
|
||||||
// On Android, compact Unicode symbols fit all 7 buttons in one row.
|
|
||||||
// On desktop, keep the descriptive text labels.
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
let col_gap = Val::Px(4.0);
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
let col_gap = VAL_SPACE_2;
|
|
||||||
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
let labels = (
|
|
||||||
/* menu */ "\u{2261}", // ≡ identical-to (Arrows/Math-Op, confirmed FiraMono)
|
|
||||||
/* undo */ "\u{2190}", // ← leftwards arrow (Arrows block, confirmed FiraMono)
|
|
||||||
/* pause */ "||", // || ASCII double-pipe — ‖ (U+2016) absent from FiraMono
|
|
||||||
/* help */ "?",
|
|
||||||
/* hint */ ANDROID_HINT_LABEL,
|
|
||||||
/* modes */ "M", // plain ASCII — U+21BB and U+21C4 both render as tofu on FiraMono
|
|
||||||
/* new */ "+",
|
|
||||||
);
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
let labels = (
|
|
||||||
"Menu \u{2193}",
|
|
||||||
"Undo",
|
|
||||||
"Pause",
|
|
||||||
"Help",
|
|
||||||
"Hint",
|
|
||||||
"Modes \u{2193}",
|
|
||||||
"New Game",
|
|
||||||
);
|
|
||||||
|
|
||||||
// Bottom bar: full-width, centered, sits above the gesture-navigation zone.
|
// Bottom bar: full-width, centered, sits above the gesture-navigation zone.
|
||||||
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
|
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
|
||||||
// Android reports it (frames 1-3); initial value is 0.0.
|
// Android reports it (frames 1-3); initial value is 0.0.
|
||||||
@@ -917,7 +894,7 @@ fn spawn_action_buttons(
|
|||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
flex_wrap: FlexWrap::Wrap,
|
flex_wrap: FlexWrap::Wrap,
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
column_gap: col_gap,
|
column_gap: ACTION_BAR_COLUMN_GAP,
|
||||||
row_gap: VAL_SPACE_2,
|
row_gap: VAL_SPACE_2,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
padding: UiRect {
|
padding: UiRect {
|
||||||
@@ -938,13 +915,13 @@ fn spawn_action_buttons(
|
|||||||
// so Tab cycles the action bar in visual reading order.
|
// so Tab cycles the action bar in visual reading order.
|
||||||
// Undo and Pause are the primary gameplay actions — full brightness.
|
// Undo and Pause are the primary gameplay actions — full brightness.
|
||||||
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
|
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
|
||||||
spawn_action_button(row, MenuButton, labels.0, None, "Open Stats, Achievements, Profile, Settings, or Leaderboard.", &font, 0, TEXT_SECONDARY);
|
spawn_action_button(row, MenuButton, ACTION_BAR_LABELS[0], None, "Open Stats, Achievements, Profile, Settings, or Leaderboard.", &font, 0, TEXT_SECONDARY);
|
||||||
spawn_action_button(row, UndoButton, labels.1, Some("U"), "Take back your last move. Costs points and blocks No Undo.", &font, 1, TEXT_PRIMARY);
|
spawn_action_button(row, UndoButton, ACTION_BAR_LABELS[1], Some("U"), "Take back your last move. Costs points and blocks No Undo.", &font, 1, TEXT_PRIMARY);
|
||||||
spawn_action_button(row, PauseButton, labels.2, Some("Esc"), "Pause the game and freeze the timer.", &font, 2, TEXT_PRIMARY);
|
spawn_action_button(row, PauseButton, ACTION_BAR_LABELS[2], Some("Esc"), "Pause the game and freeze the timer.", &font, 2, TEXT_PRIMARY);
|
||||||
spawn_action_button(row, HelpButton, labels.3, Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3, TEXT_SECONDARY);
|
spawn_action_button(row, HelpButton, ACTION_BAR_LABELS[3], Some("F1"), "Show controls, rules, and keyboard shortcuts.", &font, 3, TEXT_SECONDARY);
|
||||||
spawn_action_button(row, HintButton, labels.4, Some("H"), "Highlight a suggested move. Cycles through alternatives on repeat taps.", &font, 4, TEXT_SECONDARY);
|
spawn_action_button(row, HintButton, ACTION_BAR_LABELS[4], Some("H"), "Highlight a suggested move. Cycles through alternatives on repeat taps.", &font, 4, TEXT_SECONDARY);
|
||||||
spawn_action_button(row, ModesButton, labels.5, None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", &font, 5, TEXT_SECONDARY);
|
spawn_action_button(row, ModesButton, ACTION_BAR_LABELS[5], None, "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", &font, 5, TEXT_SECONDARY);
|
||||||
spawn_action_button(row, NewGameButton,labels.6, Some("N"), "Start a fresh deal. Confirms first if a game is in progress.", &font, 6, TEXT_SECONDARY);
|
spawn_action_button(row, NewGameButton, ACTION_BAR_LABELS[6], Some("N"), "Start a fresh deal. Confirms first if a game is in progress.", &font, 6, TEXT_SECONDARY);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -973,25 +950,16 @@ fn spawn_action_button<M: Component>(
|
|||||||
) {
|
) {
|
||||||
// Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a
|
// Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a
|
||||||
// touch device — the button itself is the affordance — and they
|
// touch device — the button itself is the affordance — and they
|
||||||
// visibly clutter the narrow-viewport action row. Force the hint
|
// visibly clutter the narrow-viewport action row. The chevrons on
|
||||||
// off on Android; the chevrons on Menu/Modes remain because they
|
// Menu/Modes remain because they indicate dropdown behaviour.
|
||||||
// indicate dropdown behaviour and still apply on touch.
|
let hotkey = if SHOW_KEYBOARD_ACCELERATORS { hotkey } else { None };
|
||||||
let hotkey = if cfg!(target_os = "android") { None } else { hotkey };
|
|
||||||
|
|
||||||
let hotkey_font = TextFont {
|
let hotkey_font = TextFont {
|
||||||
font: font.font.clone(),
|
font: font.font.clone(),
|
||||||
font_size: TYPE_CAPTION,
|
font_size: TYPE_CAPTION,
|
||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
// On Android, use tighter padding and a slightly smaller min-size so all
|
let (pad, min_w, min_h) = action_button_metrics();
|
||||||
// 7 icon-label buttons fit in one row on a ~411 dp phone. 44 dp ≥
|
|
||||||
// Apple's minimum touch target; padding of 4 dp each side keeps the icon
|
|
||||||
// centred with room to breathe. On desktop, keep the comfortable 48 dp
|
|
||||||
// floor and 8 dp side padding.
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
let (pad, min_w, min_h) = (UiRect::axes(Val::Px(4.0), Val::Px(4.0)), Val::Px(52.0), Val::Px(44.0));
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
let (pad, min_w, min_h) = (UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), Val::Px(48.0), Val::Px(48.0));
|
|
||||||
|
|
||||||
row.spawn((
|
row.spawn((
|
||||||
marker,
|
marker,
|
||||||
@@ -1017,10 +985,7 @@ fn spawn_action_button<M: Component>(
|
|||||||
HighContrastBorder::with_default(BORDER_SUBTLE),
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
#[cfg(target_os = "android")]
|
spawn_action_button_label(b, label, font, text_color);
|
||||||
b.spawn((ActionButtonLabel, Text::new(label), font.clone(), TextColor(text_color)));
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
b.spawn((Text::new(label), font.clone(), TextColor(text_color)));
|
|
||||||
if let Some(key) = hotkey {
|
if let Some(key) = hotkey {
|
||||||
// Hotkey hint rendered as a dim caption next to the label —
|
// Hotkey hint rendered as a dim caption next to the label —
|
||||||
// keeps the keyboard accelerator discoverable without
|
// keeps the keyboard accelerator discoverable without
|
||||||
@@ -1096,11 +1061,7 @@ fn handle_hint_button(
|
|||||||
}
|
}
|
||||||
let Some(ref g) = game else { return };
|
let Some(ref g) = game else { return };
|
||||||
if g.0.is_won {
|
if g.0.is_won {
|
||||||
#[cfg(target_os = "android")]
|
info_toast.write(InfoToastEvent(HINT_WON_MSG.to_string()));
|
||||||
let won_msg = "Game won! Tap New Game to play again";
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
let won_msg = "Game won! Press N for a new game";
|
|
||||||
info_toast.write(InfoToastEvent(won_msg.to_string()));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) {
|
if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) {
|
||||||
@@ -1195,10 +1156,7 @@ fn spawn_modes_popover(
|
|||||||
// Popover opens upward from just above the bottom action bar.
|
// Popover opens upward from just above the bottom action bar.
|
||||||
// Use a platform-aware offset that clears the bar height + safe-area
|
// Use a platform-aware offset that clears the bar height + safe-area
|
||||||
// gesture zone on Android, and the flat bar height on desktop.
|
// gesture zone on Android, and the flat bar height on desktop.
|
||||||
#[cfg(target_os = "android")]
|
let popover_bottom = Val::Px(ACTION_POPOVER_BOTTOM_PX);
|
||||||
let popover_bottom = Val::Px(200.0);
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
let popover_bottom = Val::Px(80.0);
|
|
||||||
|
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
@@ -1393,10 +1351,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
|||||||
];
|
];
|
||||||
|
|
||||||
// Same upward-opening placement as ModesPopover.
|
// Same upward-opening placement as ModesPopover.
|
||||||
#[cfg(target_os = "android")]
|
let popover_bottom = Val::Px(ACTION_POPOVER_BOTTOM_PX);
|
||||||
let popover_bottom = Val::Px(200.0);
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
let popover_bottom = Val::Px(80.0);
|
|
||||||
|
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
@@ -2511,14 +2466,37 @@ fn restore_hud_on_modal(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the action-bar glyph font size for a given logical window width.
|
/// Returns the action-bar label font size for a given logical window width.
|
||||||
/// Scales linearly so glyphs remain legible at any phone density.
|
|
||||||
#[cfg(target_os = "android")]
|
|
||||||
fn action_bar_font_size(window_width: f32) -> f32 {
|
fn action_bar_font_size(window_width: f32) -> f32 {
|
||||||
// ~1/40 of the window width gives ~22 px on a 900 logical-px phone.
|
if USE_TOUCH_UI_LAYOUT {
|
||||||
// Clamped so it never goes too tiny on narrow viewports or too large
|
// ~1/40 of the window width gives ~22 px on a 900 logical-px phone.
|
||||||
// on landscape tablets.
|
// Clamped so it never goes too tiny on narrow viewports or too large
|
||||||
(window_width / 40.0).clamp(16.0, 30.0)
|
// on landscape tablets.
|
||||||
|
(window_width / 40.0).clamp(16.0, 30.0)
|
||||||
|
} else {
|
||||||
|
TYPE_BODY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn action_button_metrics() -> (UiRect, Val, Val) {
|
||||||
|
if USE_TOUCH_UI_LAYOUT {
|
||||||
|
(UiRect::axes(Val::Px(4.0), Val::Px(4.0)), Val::Px(52.0), Val::Px(44.0))
|
||||||
|
} else {
|
||||||
|
(UiRect::axes(VAL_SPACE_2, VAL_SPACE_2), Val::Px(48.0), Val::Px(48.0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_action_button_label(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
label: &str,
|
||||||
|
font: &TextFont,
|
||||||
|
text_color: Color,
|
||||||
|
) {
|
||||||
|
if USE_TOUCH_UI_LAYOUT {
|
||||||
|
parent.spawn((ActionButtonLabel, Text::new(label), font.clone(), TextColor(text_color)));
|
||||||
|
} else {
|
||||||
|
parent.spawn((Text::new(label), font.clone(), TextColor(text_color)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resizes the glyph text inside every [`ActionButtonLabel`] to match the
|
/// Resizes the glyph text inside every [`ActionButtonLabel`] to match the
|
||||||
|
|||||||
@@ -287,12 +287,21 @@ fn spawn_slide(commands: &mut Commands, index: u8, font_res: Option<&FontResourc
|
|||||||
0 => spawn_slide_welcome(commands, font_res),
|
0 => spawn_slide_welcome(commands, font_res),
|
||||||
1 => spawn_slide_how_to_play(commands, font_res),
|
1 => spawn_slide_how_to_play(commands, font_res),
|
||||||
// Slide 2 (keyboard shortcuts) is desktop-only; Android has no keyboard.
|
// Slide 2 (keyboard shortcuts) is desktop-only; Android has no keyboard.
|
||||||
#[cfg(not(target_os = "android"))]
|
2 => spawn_slide_hotkeys_if_available(commands, font_res),
|
||||||
2 => spawn_slide_hotkeys(commands, font_res),
|
|
||||||
_ => spawn_slide_welcome(commands, font_res),
|
_ => spawn_slide_welcome(commands, font_res),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
fn spawn_slide_hotkeys_if_available(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||||
|
spawn_slide_hotkeys(commands, font_res);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
fn spawn_slide_hotkeys_if_available(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||||
|
spawn_slide_welcome(commands, font_res);
|
||||||
|
}
|
||||||
|
|
||||||
/// Slide 1 — Welcome.
|
/// Slide 1 — Welcome.
|
||||||
fn spawn_slide_welcome(commands: &mut Commands, font_res: Option<&FontResource>) {
|
fn spawn_slide_welcome(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||||
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use bevy::prelude::Resource;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Abstracts platform-specific clipboard access for gameplay UI systems.
|
||||||
|
pub trait ClipboardBackend: Send + Sync + 'static {
|
||||||
|
/// Write plain text to the active OS clipboard.
|
||||||
|
fn set_text(&self, text: &str) -> Result<(), ClipboardError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bevy resource that exposes the active clipboard backend.
|
||||||
|
#[derive(Resource, Clone)]
|
||||||
|
pub struct ClipboardBackendResource(pub Arc<dyn ClipboardBackend>);
|
||||||
|
|
||||||
|
/// Errors surfaced by platform clipboard backends.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ClipboardError {
|
||||||
|
#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
|
||||||
|
#[error(transparent)]
|
||||||
|
Native(#[from] arboard::Error),
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[error("android clipboard failed: {0}")]
|
||||||
|
Android(String),
|
||||||
|
#[cfg(all(target_arch = "wasm32", not(target_os = "android")))]
|
||||||
|
#[error("clipboard backend unavailable on wasm32")]
|
||||||
|
Unsupported,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Construct the default clipboard backend for the current platform.
|
||||||
|
pub fn default_clipboard_backend() -> Result<Arc<dyn ClipboardBackend>, ClipboardError> {
|
||||||
|
#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
|
||||||
|
{
|
||||||
|
Ok(Arc::new(NativeClipboardBackend))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
Ok(Arc::new(AndroidClipboardBackend))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(all(target_arch = "wasm32", not(target_os = "android")))]
|
||||||
|
{
|
||||||
|
Err(ClipboardError::Unsupported)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `arboard`-backed clipboard bridge for desktop targets.
|
||||||
|
#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct NativeClipboardBackend;
|
||||||
|
|
||||||
|
#[cfg(all(not(target_os = "android"), not(target_arch = "wasm32")))]
|
||||||
|
impl ClipboardBackend for NativeClipboardBackend {
|
||||||
|
fn set_text(&self, text: &str) -> Result<(), ClipboardError> {
|
||||||
|
let mut clipboard = arboard::Clipboard::new()?;
|
||||||
|
clipboard.set_text(text.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JNI-backed clipboard bridge for Android targets.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[derive(Debug, Default, Clone, Copy)]
|
||||||
|
pub struct AndroidClipboardBackend;
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
impl ClipboardBackend for AndroidClipboardBackend {
|
||||||
|
fn set_text(&self, text: &str) -> Result<(), ClipboardError> {
|
||||||
|
crate::android_clipboard::set_text(text).map_err(ClipboardError::Android)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,28 @@
|
|||||||
//! Platform abstraction layer.
|
//! Platform abstraction layer.
|
||||||
//!
|
//!
|
||||||
//! Traits defined here are implemented per target:
|
//! Target-specific implementations live here so gameplay and rendering systems
|
||||||
//! - native builds use filesystem-backed storage
|
//! can depend on stable engine-facing abstractions instead of sprinkling
|
||||||
//! - browser builds use `localStorage`
|
//! `#[cfg(...)]` branches through UI code.
|
||||||
|
|
||||||
|
pub mod clipboard;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod time;
|
pub mod time;
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
/// `false` on touch-first Android builds, where UI buttons replace keyboard chips.
|
||||||
|
pub const SHOW_KEYBOARD_ACCELERATORS: bool = false;
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
/// `true` on desktop builds, where keyboard chips should be rendered.
|
||||||
|
pub const SHOW_KEYBOARD_ACCELERATORS: bool = true;
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
/// `true` when the engine should prefer touch-optimised HUD affordances.
|
||||||
|
pub const USE_TOUCH_UI_LAYOUT: bool = true;
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
/// `false` when the engine should prefer desktop HUD affordances.
|
||||||
|
pub const USE_TOUCH_UI_LAYOUT: bool = false;
|
||||||
|
|
||||||
|
pub use clipboard::{ClipboardBackend, ClipboardBackendResource, default_clipboard_backend};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use storage::NativeStorage;
|
pub use storage::NativeStorage;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ use chrono::Datelike;
|
|||||||
|
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
|
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
|
||||||
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
|
use crate::events::{DrawRequestEvent, MoveRequestEvent, StateChangedEvent, UndoRequestEvent};
|
||||||
use crate::replay_playback::{
|
use crate::replay_playback::{
|
||||||
step_backwards_replay_playback, step_replay_playback, stop_replay_playback,
|
step_backwards_replay_playback, step_replay_playback, stop_replay_playback,
|
||||||
@@ -971,16 +972,17 @@ fn spawn_overlay(
|
|||||||
},
|
},
|
||||||
TextColor(TEXT_SECONDARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
));
|
));
|
||||||
#[cfg(not(target_os = "android"))]
|
if SHOW_KEYBOARD_ACCELERATORS {
|
||||||
footer.spawn((
|
footer.spawn((
|
||||||
Text::new(keybind_footer_hint_text()),
|
Text::new(keybind_footer_hint_text()),
|
||||||
TextFont {
|
TextFont {
|
||||||
font: font_handle_for_labels.clone(),
|
font: font_handle_for_labels.clone(),
|
||||||
font_size: TYPE_CAPTION,
|
font_size: TYPE_CAPTION,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
TextColor(TEXT_SECONDARY),
|
TextColor(TEXT_SECONDARY),
|
||||||
));
|
));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1256,9 +1258,12 @@ fn keybind_footer_mode_text() -> &'static str {
|
|||||||
/// pause/resume, the ESC accelerator for stop, and the ← / →
|
/// pause/resume, the ESC accelerator for stop, and the ← / →
|
||||||
/// accelerators for paused single-move stepping. The footer never
|
/// accelerators for paused single-move stepping. The footer never
|
||||||
/// lists unimplemented keybinds (would lie to users).
|
/// lists unimplemented keybinds (would lie to users).
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
fn keybind_footer_hint_text() -> &'static str {
|
fn keybind_footer_hint_text() -> &'static str {
|
||||||
"[SPACE] pause/resume \u{00B7} [ESC] stop \u{00B7} [\u{2190}\u{2192}] step" // · separator
|
if SHOW_KEYBOARD_ACCELERATORS {
|
||||||
|
"[SPACE] pause/resume \u{00B7} [ESC] stop \u{00B7} [\u{2190}\u{2192}] step" // · separator
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pure helper — returns the WIN MOVE marker's left-edge position as
|
/// Pure helper — returns the WIN MOVE marker's left-edge position as
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use crate::events::{
|
|||||||
WinStreakMilestoneEvent,
|
WinStreakMilestoneEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
|
use crate::platform::ClipboardBackendResource;
|
||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
@@ -77,8 +78,8 @@ pub struct ReplayHistoryResource(pub ReplayHistory);
|
|||||||
|
|
||||||
/// Marker on the "Copy share link" button inside the Stats modal.
|
/// Marker on the "Copy share link" button inside the Stats modal.
|
||||||
/// Click reads the share URL from the currently-selected replay
|
/// Click reads the share URL from the currently-selected replay
|
||||||
/// (`history.0.replays[selected.0].share_url`) and writes it to the
|
/// (`history.0.replays[selected.0].share_url`) and writes it through the
|
||||||
/// OS clipboard via `arboard`, surfacing a confirmation toast. The
|
/// active platform clipboard backend, surfacing a confirmation toast. The
|
||||||
/// share URL is populated by `sync_plugin::poll_replay_upload_result`
|
/// share URL is populated by `sync_plugin::poll_replay_upload_result`
|
||||||
/// when the corresponding win's upload completes and is persisted to
|
/// when the corresponding win's upload completes and is persisted to
|
||||||
/// `replays.json` so it survives a restart.
|
/// `replays.json` so it survives a restart.
|
||||||
@@ -309,19 +310,19 @@ fn refresh_replay_history_on_win(
|
|||||||
/// resets the live game to the recorded deal and ticks through the
|
/// resets the live game to the recorded deal and ticks through the
|
||||||
/// move list via [`crate::replay_playback`]; the
|
/// move list via [`crate::replay_playback`]; the
|
||||||
/// [`crate::replay_overlay`] banner surfaces while playback runs.
|
/// [`crate::replay_overlay`] banner surfaces while playback runs.
|
||||||
/// Copies the currently-selected replay's `share_url` to the OS
|
/// Copies the currently-selected replay's `share_url` through the
|
||||||
/// clipboard via `arboard` and surfaces a confirmation toast. When no
|
/// active platform clipboard backend and surfaces a confirmation toast.
|
||||||
/// URL is in hand on the selected entry (replay never uploaded — the
|
/// When no URL is in hand on the selected entry (replay never uploaded
|
||||||
/// player won on a local-only backend, the upload failed, or the
|
/// — the player won on a local-only backend, the upload failed, or the
|
||||||
/// replay pre-dates v0.19.0 share-link persistence) the button still
|
/// replay pre-dates v0.19.0 share-link persistence) the button still
|
||||||
/// acknowledges the click but explains why the clipboard wasn't
|
/// acknowledges the click but explains why the clipboard wasn't
|
||||||
/// written. `arboard::Clipboard::new()` failures are logged + surfaced
|
/// written. Backend failures are logged and fall back to surfacing the
|
||||||
/// as a generic "couldn't reach the clipboard" toast rather than
|
/// share URL directly in a toast.
|
||||||
/// swallowed — they're rare but worth diagnosing.
|
|
||||||
fn handle_copy_share_link_button(
|
fn handle_copy_share_link_button(
|
||||||
buttons: Query<&Interaction, (With<CopyShareLinkButton>, Changed<Interaction>)>,
|
buttons: Query<&Interaction, (With<CopyShareLinkButton>, Changed<Interaction>)>,
|
||||||
history: Res<ReplayHistoryResource>,
|
history: Res<ReplayHistoryResource>,
|
||||||
selected: Res<SelectedReplayIndex>,
|
selected: Res<SelectedReplayIndex>,
|
||||||
|
clipboard: Option<Res<ClipboardBackendResource>>,
|
||||||
mut toast: MessageWriter<InfoToastEvent>,
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
|
||||||
@@ -339,42 +340,18 @@ fn handle_copy_share_link_button(
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Desktop: `arboard` writes the URL to the OS clipboard.
|
let Some(clipboard) = clipboard else {
|
||||||
// Android: `arboard` has no platform backend (would fail to
|
toast.write(InfoToastEvent(format!("Share link: {url}")));
|
||||||
// compile, so the dependency is target-gated in
|
return;
|
||||||
// solitaire_engine/Cargo.toml). The button still spawns and
|
};
|
||||||
// resolves to a meaningful toast instead — when we wire the
|
|
||||||
// Android Phase, this becomes a JNI call into ClipboardManager.
|
match clipboard.0.set_text(url) {
|
||||||
#[cfg(not(target_os = "android"))]
|
Ok(()) => {
|
||||||
{
|
toast.write(InfoToastEvent(format!("Copied: {url}")));
|
||||||
match arboard::Clipboard::new() {
|
|
||||||
Ok(mut cb) => match cb.set_text(url.clone()) {
|
|
||||||
Ok(()) => {
|
|
||||||
toast.write(InfoToastEvent(format!("Copied: {url}")));
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
warn!("clipboard write failed: {e}");
|
|
||||||
toast.write(InfoToastEvent(
|
|
||||||
"Couldn't write to clipboard \u{2014} share link wasn't copied.".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(e) => {
|
|
||||||
warn!("clipboard init failed: {e}");
|
|
||||||
toast.write(InfoToastEvent(
|
|
||||||
"Couldn't reach the clipboard \u{2014} share link wasn't copied.".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
Err(e) => {
|
||||||
#[cfg(target_os = "android")]
|
warn!("clipboard write failed: {e}");
|
||||||
{
|
toast.write(InfoToastEvent(format!("Share link: {url}")));
|
||||||
match crate::android_clipboard::set_text(&url) {
|
|
||||||
Ok(()) => { toast.write(InfoToastEvent(format!("Copied: {url}"))); }
|
|
||||||
Err(e) => {
|
|
||||||
warn!("android clipboard failed: {e}");
|
|
||||||
toast.write(InfoToastEvent(format!("Share link: {url}")));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ use crate::events::{
|
|||||||
SyncLogoutRequestEvent,
|
SyncLogoutRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
|
||||||
use crate::settings_plugin::{SettingsPanel, SettingsResource, SettingsScreen, SettingsStoragePath};
|
use crate::settings_plugin::{SettingsPanel, SettingsResource, SettingsScreen, SettingsStoragePath};
|
||||||
use crate::resources::TokioRuntimeResource;
|
use crate::resources::TokioRuntimeResource;
|
||||||
use crate::sync_plugin::SyncProviderResource;
|
use crate::sync_plugin::SyncProviderResource;
|
||||||
@@ -723,13 +724,14 @@ fn spawn_sync_setup_modal(commands: &mut Commands, font_res: Option<&FontResourc
|
|||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tab hint — desktop only; no Tab key on Android.
|
// Tab hint — desktop only; no Tab key on touch-first Android builds.
|
||||||
#[cfg(not(target_os = "android"))]
|
if SHOW_KEYBOARD_ACCELERATORS {
|
||||||
body.spawn((
|
body.spawn((
|
||||||
Text::new("Tab = next field"),
|
Text::new("Tab = next field"),
|
||||||
make_font(font_res, TYPE_CAPTION),
|
make_font(font_res, TYPE_CAPTION),
|
||||||
TextColor(TEXT_DISABLED),
|
TextColor(TEXT_DISABLED),
|
||||||
));
|
));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Action row.
|
// Action row.
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ use bevy::window::PrimaryWindow;
|
|||||||
use solitaire_data::AnimSpeed;
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
|
||||||
use crate::settings_plugin::SettingsResource;
|
use crate::settings_plugin::SettingsResource;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
scaled_duration, ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED,
|
scaled_duration, ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED,
|
||||||
@@ -342,7 +343,7 @@ pub fn spawn_modal_button<M: Component>(
|
|||||||
variant: ButtonVariant,
|
variant: ButtonVariant,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
) {
|
) {
|
||||||
let hotkey = if cfg!(target_os = "android") { None } else { hotkey };
|
let hotkey = if SHOW_KEYBOARD_ACCELERATORS { hotkey } else { None };
|
||||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
let font_label = TextFont {
|
let font_label = TextFont {
|
||||||
font: font_handle.clone(),
|
font: font_handle.clone(),
|
||||||
|
|||||||
Reference in New Issue
Block a user