feat(replay): add full-screen tableau dim layer for mini-tableau preview
Spawn a `ReplayTableauDimLayer` UI node (100% × 100%, 50% opacity black) at z=54 (Z_REPLAY_OVERLAY − 1) whenever a replay starts. The dim layer darkens the entire card world so the replay chrome (banner at z=55, move-log panel at z=55) reads clearly against the scene without obscuring card positions — matching the mockup's "Game Peek Band at 50% opacity" spec. Bevy's UI/world compositor means no changes to card_plugin are needed: UI nodes always render above world-space sprites regardless of Transform.z values. The dim layer carries no Interaction component (purely visual; pointer events pass through). Despawned alongside the banner and move-log panel in `react_to_state_change` when the replay ends. Adds Z_REPLAY_DIM (= 54) and TABLEAU_DIM_ALPHA (= 0.5) constants plus two new tests: lifecycle (spawn/despawn mirrors floating chip pattern) and z-ordering invariant (Z_REPLAY_DIM < Z_REPLAY_OVERLAY pinned). 1275 tests pass / 0 failing. Closes the last major B-2 sub-piece. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -60,6 +60,23 @@ use crate::ui_theme::{
|
|||||||
/// we materialise a separate constant rather than reuse the `f32` value.
|
/// we materialise a separate constant rather than reuse the `f32` value.
|
||||||
pub const Z_REPLAY_OVERLAY: i32 = Z_DROP_OVERLAY as i32 + 5;
|
pub const Z_REPLAY_OVERLAY: i32 = Z_DROP_OVERLAY as i32 + 5;
|
||||||
|
|
||||||
|
/// `bevy::ui` `ZIndex` for the full-screen tableau dim layer.
|
||||||
|
///
|
||||||
|
/// One rung below [`Z_REPLAY_OVERLAY`] (= 54) so the replay chrome
|
||||||
|
/// (banner + move-log panel) renders clearly on top while the dim scrim
|
||||||
|
/// darkens the card world beneath it. World-space sprites (cards,
|
||||||
|
/// badges, drop-target overlays) are always below any UI node regardless
|
||||||
|
/// of their Transform.z — the dim layer doesn't need to know their z
|
||||||
|
/// values.
|
||||||
|
const Z_REPLAY_DIM: i32 = Z_REPLAY_OVERLAY - 1;
|
||||||
|
|
||||||
|
/// Alpha for the tableau dim layer — 50 % opacity black. Dark enough
|
||||||
|
/// to visually separate the gameplay scene from the replay chrome
|
||||||
|
/// above it; light enough that card positions remain legible through
|
||||||
|
/// the scrim. Matches the mockup's "Game Peek Band at 50 % opacity"
|
||||||
|
/// spec in `docs/ui-mockups/replay-overlay-mobile.html`.
|
||||||
|
const TABLEAU_DIM_ALPHA: f32 = 0.5;
|
||||||
|
|
||||||
/// Total height of the banner in pixels. Thin enough to leave the
|
/// Total height of the banner in pixels. Thin enough to leave the
|
||||||
/// gameplay surface visible underneath, tall enough to comfortably fit
|
/// gameplay surface visible underneath, tall enough to comfortably fit
|
||||||
/// the headline-sized "▌ replay" label stacked above the
|
/// the headline-sized "▌ replay" label stacked above the
|
||||||
@@ -189,6 +206,18 @@ pub struct ReplayPauseButton;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct ReplayStepButton;
|
pub struct ReplayStepButton;
|
||||||
|
|
||||||
|
/// Marker on the full-screen tableau dim layer spawned at the start of
|
||||||
|
/// every replay. The dim layer is a 100 % × 100 % `Node` at
|
||||||
|
/// [`Z_REPLAY_DIM`] (= `Z_REPLAY_OVERLAY - 1`) with a semi-transparent
|
||||||
|
/// black `BackgroundColor`. It darkens the card world so the replay
|
||||||
|
/// chrome reads clearly against it without obscuring card positions.
|
||||||
|
///
|
||||||
|
/// Carries no [`Interaction`] component — purely visual; pointer events
|
||||||
|
/// pass through to the underlying UI and world-space systems.
|
||||||
|
/// Despawned by `react_to_state_change` when the replay ends.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ReplayTableauDimLayer;
|
||||||
|
|
||||||
/// Marker on the small caption sitting below the "▌ replay"
|
/// Marker on the small caption sitting below the "▌ replay"
|
||||||
/// headline. Carries `GAME #YYYY-DDD` (year + chrono ordinal) while a
|
/// headline. Carries `GAME #YYYY-DDD` (year + chrono ordinal) while a
|
||||||
/// replay is playing — a compact, monotonically-increasing identifier
|
/// replay is playing — a compact, monotonically-increasing identifier
|
||||||
@@ -435,6 +464,7 @@ fn react_to_state_change(
|
|||||||
existing: Query<Entity, With<ReplayOverlayRoot>>,
|
existing: Query<Entity, With<ReplayOverlayRoot>>,
|
||||||
floating_chips: Query<Entity, With<ReplayFloatingProgressChip>>,
|
floating_chips: Query<Entity, With<ReplayFloatingProgressChip>>,
|
||||||
move_log_panels: Query<Entity, With<ReplayOverlayMoveLogPanel>>,
|
move_log_panels: Query<Entity, With<ReplayOverlayMoveLogPanel>>,
|
||||||
|
dim_layers: Query<Entity, With<ReplayTableauDimLayer>>,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
if !state.is_changed() {
|
if !state.is_changed() {
|
||||||
@@ -463,6 +493,11 @@ fn react_to_state_change(
|
|||||||
for entity in &move_log_panels {
|
for entity in &move_log_panels {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
}
|
}
|
||||||
|
// Tableau dim layer is also a separate root entity — same
|
||||||
|
// pattern as the move-log panel.
|
||||||
|
for entity in &dim_layers {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// The `should_be_visible && already_spawned` branch is a no-op here —
|
// The `should_be_visible && already_spawned` branch is a no-op here —
|
||||||
// the per-frame text update systems below repaint the banner label
|
// the per-frame text update systems below repaint the banner label
|
||||||
@@ -504,6 +539,27 @@ fn spawn_overlay(
|
|||||||
};
|
};
|
||||||
let progress_label = format_progress(state);
|
let progress_label = format_progress(state);
|
||||||
|
|
||||||
|
// Tableau dim layer — full-screen scrim at z = Z_REPLAY_DIM (= 54).
|
||||||
|
// Spawned first so it sits behind the banner (z=55) and move-log (z=55)
|
||||||
|
// in the UI stacking context. World-space sprites (cards, badges) are
|
||||||
|
// always below any UI node, so the dim layer darkens the entire
|
||||||
|
// gameplay scene without needing to touch card_plugin. No Interaction
|
||||||
|
// component — purely visual.
|
||||||
|
commands.spawn((
|
||||||
|
ReplayTableauDimLayer,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
left: Val::Px(0.0),
|
||||||
|
top: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
height: Val::Percent(100.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, TABLEAU_DIM_ALPHA)),
|
||||||
|
ZIndex(Z_REPLAY_DIM),
|
||||||
|
GlobalZIndex(Z_REPLAY_DIM),
|
||||||
|
));
|
||||||
|
|
||||||
let banner_bg = Color::srgba(
|
let banner_bg = Color::srgba(
|
||||||
BG_ELEVATED_HI.to_srgba().red,
|
BG_ELEVATED_HI.to_srgba().red,
|
||||||
BG_ELEVATED_HI.to_srgba().green,
|
BG_ELEVATED_HI.to_srgba().green,
|
||||||
@@ -3798,4 +3854,65 @@ mod tests {
|
|||||||
other => panic!("expected Playing, got {other:?}"),
|
other => panic!("expected Playing, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The tableau dim layer spawns alongside the banner when playback
|
||||||
|
/// starts and despawns when the replay ends. Mirrors
|
||||||
|
/// `floating_chip_spawns_and_despawns_with_overlay` for the dim layer.
|
||||||
|
#[test]
|
||||||
|
fn dim_layer_spawns_and_despawns_with_overlay() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
|
||||||
|
// Inactive → no dim layer yet.
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&ReplayTableauDimLayer>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
"no dim layer while playback is Inactive",
|
||||||
|
);
|
||||||
|
|
||||||
|
set_state(
|
||||||
|
&mut app,
|
||||||
|
ReplayPlaybackState::Playing {
|
||||||
|
replay: synthetic_replay(5),
|
||||||
|
cursor: 0,
|
||||||
|
secs_to_next: 0.5,
|
||||||
|
paused: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&ReplayTableauDimLayer>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"dim layer must spawn when playback starts",
|
||||||
|
);
|
||||||
|
|
||||||
|
set_state(&mut app, ReplayPlaybackState::Inactive);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&ReplayTableauDimLayer>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
"dim layer must despawn when playback ends",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dim layer is a full-screen node (100 % × 100 %) at a lower
|
||||||
|
/// z-index than the replay chrome (z = Z_REPLAY_DIM < Z_REPLAY_OVERLAY).
|
||||||
|
/// Lock the z-ordering so a future refactor of the z constants can't
|
||||||
|
/// silently flip the intended stacking.
|
||||||
|
#[test]
|
||||||
|
fn dim_layer_z_is_below_replay_chrome() {
|
||||||
|
assert!(
|
||||||
|
Z_REPLAY_DIM < Z_REPLAY_OVERLAY,
|
||||||
|
"dim layer (z={Z_REPLAY_DIM}) must be below replay chrome (z={Z_REPLAY_OVERLAY})",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user