refactor(engine): unify toasts into one bottom-anchored stack (Phase H)
Queued and immediate toasts used two subtly different geometries and staggered anchors purely to dodge each other when simultaneous. Both paths now spawn geometry-free ToastNodes that adopt_toasts_into_stack slots into a single persistent ToastStackRoot flex column — one anchor, one style, simultaneous toasts stack upward. The root rides SafeAreaAnchoredBottom and clears the Phase F touch action bar. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,8 @@ use crate::game_plugin::GameMutation;
|
|||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
use crate::progress_plugin::LevelUpEvent;
|
use crate::progress_plugin::LevelUpEvent;
|
||||||
|
use crate::platform::USE_TOUCH_UI_LAYOUT;
|
||||||
|
use crate::safe_area::SafeAreaAnchoredBottom;
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
@@ -160,6 +162,68 @@ pub struct ActiveToast {
|
|||||||
/// Duration of each queued info-toast in seconds.
|
/// Duration of each queued info-toast in seconds.
|
||||||
const QUEUED_TOAST_SECS: f32 = 2.5;
|
const QUEUED_TOAST_SECS: f32 = 2.5;
|
||||||
|
|
||||||
|
/// Marker on the persistent bottom-anchored flex column every toast
|
||||||
|
/// spawns into (Phase H). Stacking through one container gives queued
|
||||||
|
/// and immediate toasts a single shared anchor — simultaneous toasts
|
||||||
|
/// stack upward instead of relying on the old staggered-percentage
|
||||||
|
/// anchors to dodge each other.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ToastStackRoot;
|
||||||
|
|
||||||
|
/// Marker on every toast card node (both paths). Freshly spawned toasts
|
||||||
|
/// start `Visibility::Hidden` and unparented; [`adopt_toasts_into_stack`]
|
||||||
|
/// re-parents them under [`ToastStackRoot`] and reveals them — one frame
|
||||||
|
/// of latency, imperceptible at toast timescales, in exchange for the 14
|
||||||
|
/// toast handlers keeping their `Commands`-only signatures.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ToastNode;
|
||||||
|
|
||||||
|
/// Logical-pixel gap between the screen bottom and the toast stack,
|
||||||
|
/// before safe-area insets. Clears the Phase F bottom action bar on
|
||||||
|
/// touch (compact 44px buttons + primary 64px trio + bar padding);
|
||||||
|
/// desktop's shorter bar needs less.
|
||||||
|
const TOAST_STACK_BASE_BOTTOM_PX: f32 = if USE_TOUCH_UI_LAYOUT { 112.0 } else { 72.0 };
|
||||||
|
|
||||||
|
/// Spawns the persistent [`ToastStackRoot`] container at startup.
|
||||||
|
fn spawn_toast_stack_root(mut commands: Commands) {
|
||||||
|
commands.spawn((
|
||||||
|
ToastStackRoot,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
bottom: Val::Px(TOAST_STACK_BASE_BOTTOM_PX),
|
||||||
|
left: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
// Newest toast sits nearest the bottom edge; older ones
|
||||||
|
// push upward.
|
||||||
|
flex_direction: FlexDirection::ColumnReverse,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
SafeAreaAnchoredBottom {
|
||||||
|
base_bottom: TOAST_STACK_BASE_BOTTOM_PX,
|
||||||
|
},
|
||||||
|
ZIndex(Z_TOAST),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-parents freshly spawned [`ToastNode`]s under the stack root and
|
||||||
|
/// reveals them. No-op when every toast is already adopted.
|
||||||
|
fn adopt_toasts_into_stack(
|
||||||
|
mut commands: Commands,
|
||||||
|
orphans: Query<Entity, (With<ToastNode>, Without<ChildOf>)>,
|
||||||
|
root: Query<Entity, With<ToastStackRoot>>,
|
||||||
|
) {
|
||||||
|
let Ok(root) = root.single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for toast in &orphans {
|
||||||
|
commands
|
||||||
|
.entity(toast)
|
||||||
|
.insert((ChildOf(root), Visibility::Inherited));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
|
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
|
||||||
pub struct AnimationPlugin;
|
pub struct AnimationPlugin;
|
||||||
|
|
||||||
@@ -185,7 +249,7 @@ impl Plugin for AnimationPlugin {
|
|||||||
.init_resource::<EffectiveSlideDuration>()
|
.init_resource::<EffectiveSlideDuration>()
|
||||||
.init_resource::<ToastQueue>()
|
.init_resource::<ToastQueue>()
|
||||||
.init_resource::<ActiveToast>()
|
.init_resource::<ActiveToast>()
|
||||||
.add_systems(Startup, init_slide_duration)
|
.add_systems(Startup, (init_slide_duration, spawn_toast_stack_root))
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -206,6 +270,7 @@ impl Plugin for AnimationPlugin {
|
|||||||
handle_warning_toast,
|
handle_warning_toast,
|
||||||
tick_toasts,
|
tick_toasts,
|
||||||
(enqueue_toasts, drive_toast_display).chain(),
|
(enqueue_toasts, drive_toast_display).chain(),
|
||||||
|
adopt_toasts_into_stack,
|
||||||
)
|
)
|
||||||
.after(GameMutation),
|
.after(GameMutation),
|
||||||
);
|
);
|
||||||
@@ -637,26 +702,14 @@ impl ToastVariant {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawns a bottom-anchored `ToastEntity` for the queued toast system.
|
/// Spawns a `ToastEntity` for the queued toast system.
|
||||||
///
|
///
|
||||||
/// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed
|
/// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed
|
||||||
/// by [`InfoToastEvent`] which is by definition neutral system info.
|
/// by [`InfoToastEvent`] which is by definition neutral system info.
|
||||||
/// Variants other than `Info` belong on the immediate-fire path
|
/// Variants other than `Info` belong on the immediate-fire path
|
||||||
/// ([`spawn_toast`]) where the call site knows the semantic intent.
|
/// ([`spawn_toast`]) where the call site knows the semantic intent.
|
||||||
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
|
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
|
||||||
spawn_toast_node(
|
spawn_toast_node(commands, ToastEntity, message, ToastVariant::Info)
|
||||||
commands,
|
|
||||||
ToastEntity,
|
|
||||||
message,
|
|
||||||
ToastVariant::Info,
|
|
||||||
// Slightly taller anchor than the immediate-fire path so a
|
|
||||||
// queued info banner doesn't collide with a celebration toast
|
|
||||||
// fired in the same frame.
|
|
||||||
Val::Percent(6.0),
|
|
||||||
Val::Percent(15.0),
|
|
||||||
Val::Percent(70.0),
|
|
||||||
UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
|
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
|
||||||
@@ -744,12 +797,6 @@ fn spawn_toast(
|
|||||||
(ToastOverlay, ToastTimer(duration_secs)),
|
(ToastOverlay, ToastTimer(duration_secs)),
|
||||||
message,
|
message,
|
||||||
variant,
|
variant,
|
||||||
// Sits above the queued banner so a celebration toast spawned
|
|
||||||
// alongside a queued info message remains readable.
|
|
||||||
Val::Percent(14.0),
|
|
||||||
Val::Percent(25.0),
|
|
||||||
Val::Percent(50.0),
|
|
||||||
UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,31 +813,25 @@ fn spawn_toast(
|
|||||||
/// rungs; 18 is the closest rung that preserves the scale invariants
|
/// rungs; 18 is the closest rung that preserves the scale invariants
|
||||||
/// tested in `ui_theme::tests`.
|
/// tested in `ui_theme::tests`.
|
||||||
/// - [`RADIUS_MD`] corners.
|
/// - [`RADIUS_MD`] corners.
|
||||||
/// - Bottom-anchored absolute position; `bottom_pct` differs between
|
///
|
||||||
/// queued and immediate paths so they layer instead of overlap.
|
/// Layout is owned by [`ToastStackRoot`] (Phase H): the node spawns
|
||||||
// The 8-argument signature is intentional — these are the per-toast
|
/// hidden and unpositioned, and [`adopt_toasts_into_stack`] slots it
|
||||||
// layout values that genuinely differ between the queued and fire-and-
|
/// into the shared bottom-anchored column — one anchor for every toast,
|
||||||
// forget call sites. A struct wrapper would just rename the same data.
|
/// simultaneous toasts stack instead of overlapping.
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn spawn_toast_node<B: Bundle>(
|
fn spawn_toast_node<B: Bundle>(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
bundle: B,
|
bundle: B,
|
||||||
message: String,
|
message: String,
|
||||||
variant: ToastVariant,
|
variant: ToastVariant,
|
||||||
bottom_pct: Val,
|
|
||||||
left_pct: Val,
|
|
||||||
width_pct: Val,
|
|
||||||
padding: UiRect,
|
|
||||||
) -> Entity {
|
) -> Entity {
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
bundle,
|
bundle,
|
||||||
|
ToastNode,
|
||||||
|
Visibility::Hidden,
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
max_width: Val::Percent(70.0),
|
||||||
left: left_pct,
|
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
|
||||||
bottom: bottom_pct,
|
|
||||||
width: width_pct,
|
|
||||||
padding,
|
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
@@ -799,7 +840,6 @@ fn spawn_toast_node<B: Bundle>(
|
|||||||
},
|
},
|
||||||
BackgroundColor(BG_ELEVATED),
|
BackgroundColor(BG_ELEVATED),
|
||||||
BorderColor::all(variant.border_color()),
|
BorderColor::all(variant.border_color()),
|
||||||
ZIndex(Z_TOAST),
|
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
@@ -1315,4 +1355,85 @@ mod tests {
|
|||||||
fn cascade_duration_instant_is_zero() {
|
fn cascade_duration_instant_is_zero() {
|
||||||
assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0);
|
assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase H: unified toast stack
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Both toast paths must end up as visible children of the single
|
||||||
|
/// [`ToastStackRoot`] — the Phase H "one anchor" contract.
|
||||||
|
#[test]
|
||||||
|
fn queued_and_immediate_toasts_stack_under_one_root() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
app.update(); // Startup: spawns the stack root.
|
||||||
|
|
||||||
|
// One immediate (error) toast + one queued (info) toast in the
|
||||||
|
// same frame — the exact collision case the old staggered
|
||||||
|
// anchors existed to dodge.
|
||||||
|
use solitaire_core::{KlondikePile, Tableau};
|
||||||
|
app.world_mut().write_message(MoveRejectedEvent {
|
||||||
|
from: KlondikePile::Tableau(Tableau::Tableau1),
|
||||||
|
to: KlondikePile::Tableau(Tableau::Tableau2),
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(InfoToastEvent("stacked info".to_string()));
|
||||||
|
app.update(); // handlers spawn both toasts (hidden, unparented)
|
||||||
|
app.update(); // adopt_toasts_into_stack re-parents + reveals
|
||||||
|
|
||||||
|
let root = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<Entity, With<ToastStackRoot>>()
|
||||||
|
.single(app.world())
|
||||||
|
.expect("exactly one ToastStackRoot must exist");
|
||||||
|
|
||||||
|
let toasts: Vec<(Entity, &ChildOf, &Visibility)> = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<(Entity, &ChildOf, &Visibility), With<ToastNode>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
toasts.len(),
|
||||||
|
2,
|
||||||
|
"both the immediate and the queued toast must be adopted"
|
||||||
|
);
|
||||||
|
for (entity, child_of, visibility) in toasts {
|
||||||
|
assert_eq!(
|
||||||
|
child_of.parent(),
|
||||||
|
root,
|
||||||
|
"toast {entity} must be a child of the shared stack root"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*visibility,
|
||||||
|
Visibility::Inherited,
|
||||||
|
"adopted toast {entity} must be revealed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toast nodes must not carry their own absolute positioning — the
|
||||||
|
/// stack root owns layout (regression guard against reintroducing
|
||||||
|
/// per-path anchors).
|
||||||
|
#[test]
|
||||||
|
fn toast_nodes_have_no_absolute_position() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(WarningToastEvent("layout check".to_string()));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let node = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&Node, With<ToastNode>>()
|
||||||
|
.single(app.world())
|
||||||
|
.expect("warning toast must spawn a ToastNode");
|
||||||
|
assert_eq!(
|
||||||
|
node.position_type,
|
||||||
|
PositionType::Relative,
|
||||||
|
"toast nodes are flex children of the stack, not absolute overlays"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user