feat(engine): add tiled scanline overlay to splash
Closes the second half of the splash polish arc deferred in cacb19c.
A fullscreen ImageNode tiles a runtime-generated 2×2 RGBA8 texture
over the splash content — top row transparent, bottom row #1a1a1a
at ~30 % alpha — producing the 1 px-pitch horizontal scanline
pattern called for in docs/ui-mockups/splash-mobile.html.
Implementation:
- New build_scanline_image() pure helper returns the 2×2 source
texture. Pixels hard-coded as RGBA bytes (0,0,0,0 / 26,26,26,76)
so the visible appearance is locked into source rather than
reconstructed from constants.
- spawn_splash gains an `Option<ResMut<Assets<Image>>>` parameter;
when present (always in production), the image is added and an
ImageNode child of the splash root tiles it via
NodeImageMode::Tiled { tile_x: true, tile_y: true, stretch_value: 1.0 }.
When absent (legacy bare-MinimalPlugins tests), the overlay is
silently skipped — the rest of the splash still spawns.
- New SplashFadableImage marker + extension to advance_splash that
writes (1, 1, 1, global_alpha) into the ImageNode tint each tick.
Multiplying (rather than overwriting like SplashFadableBg does)
preserves the per-pixel 30 % alpha in the texture so the GPU
composite is `0.3 × global_alpha` — fades cleanly with the
splash without drifting to 100 % alpha during the hold.
- New SplashScanlineOverlay marker for tests. Distinct from
SplashFadableImage so the test query intent stays explicit
(there's only one fadable image today, but adding more later
shouldn't break the scanline-locator).
Bevy 0.18 API quirks worth pinning for next time: RenderAssetUsages
is re-exported under `bevy::asset::` (not `bevy::render::render_asset`),
and TextureFormat::pixel_size() returns Result<usize, _> rather
than usize. Both fixed in the imports / debug_assert.
Headless test fixture now also init_resource::<Assets<Image>>()
since MinimalPlugins doesn't pull AssetPlugin — same pattern
settings_plugin's tests already use.
Two new tests (1183 → 1185): build_scanline_image_has_expected_2x2_rgba_bytes
locks the texture pixels literally, scanline_overlay_spawns_and_fades_with_splash
asserts spawn placement under SplashRoot and the new fade-images
branch's correctness end-to-end.
This closes Option B from the SESSION_HANDOFF Resume prompt — both
splash polish pieces (cursor pulse + scanline overlay) shipped.
This commit is contained in:
@@ -64,8 +64,12 @@
|
|||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use bevy::asset::RenderAssetUsages;
|
||||||
|
use bevy::image::Image;
|
||||||
use bevy::input::touch::Touches;
|
use bevy::input::touch::Touches;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat};
|
||||||
|
use bevy::ui::widget::NodeImageMode;
|
||||||
|
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::settings_plugin::SettingsResource;
|
use crate::settings_plugin::SettingsResource;
|
||||||
@@ -165,6 +169,24 @@ struct SplashFadableBg {
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct SplashCursorPulse;
|
struct SplashCursorPulse;
|
||||||
|
|
||||||
|
/// Marks an [`ImageNode`] whose `color` tint should fade with the
|
||||||
|
/// global splash timeline. The per-tick write is `tint = (1, 1, 1,
|
||||||
|
/// global_alpha)`, so the GPU composite is `texture_α × global_α` —
|
||||||
|
/// per-pixel transparency in the texture (e.g. the 30 %-alpha
|
||||||
|
/// scanline rows) is preserved while the whole image still fades
|
||||||
|
/// in / out with the splash. The alternative of cramming the alpha
|
||||||
|
/// into [`SplashFadableBg`] doesn't work because that writer
|
||||||
|
/// *overwrites* the base-colour alpha rather than multiplying it.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct SplashFadableImage;
|
||||||
|
|
||||||
|
/// Marker on the fullscreen scanline overlay. Distinct from
|
||||||
|
/// [`SplashFadableImage`] so tests can locate the overlay without
|
||||||
|
/// scanning every fadable image (there's only ever one, but the
|
||||||
|
/// marker makes the query intent explicit).
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct SplashScanlineOverlay;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Systems
|
// Systems
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -187,6 +209,7 @@ fn spawn_splash(
|
|||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
settings: Option<Res<SettingsResource>>,
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
images: Option<ResMut<Assets<Image>>>,
|
||||||
) {
|
) {
|
||||||
if let Some(settings) = settings.as_deref()
|
if let Some(settings) = settings.as_deref()
|
||||||
&& settings.0.first_run_complete
|
&& settings.0.first_run_complete
|
||||||
@@ -196,6 +219,13 @@ fn spawn_splash(
|
|||||||
|
|
||||||
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();
|
||||||
|
|
||||||
|
// Generate the scanline texture handle up-front (when the asset
|
||||||
|
// store is available — always true in production; opt-out under
|
||||||
|
// bare `MinimalPlugins` test fixtures so existing tests that
|
||||||
|
// don't init `Assets<Image>` keep working with the rest of the
|
||||||
|
// splash content unchanged).
|
||||||
|
let scanline_handle = images.map(|mut images| images.add(build_scanline_image()));
|
||||||
|
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
SplashRoot,
|
SplashRoot,
|
||||||
@@ -224,9 +254,80 @@ fn spawn_splash(
|
|||||||
spawn_header_section(root, &font_handle);
|
spawn_header_section(root, &font_handle);
|
||||||
spawn_centre_section(root, &font_handle);
|
spawn_centre_section(root, &font_handle);
|
||||||
spawn_footer_section(root, &font_handle);
|
spawn_footer_section(root, &font_handle);
|
||||||
|
// Scanline overlay sits last so it renders on top of the
|
||||||
|
// boot-screen content. Absolute-positioned to fill the
|
||||||
|
// root; `NodeImageMode::Tiled` repeats the 2×2 source
|
||||||
|
// texture across the whole viewport.
|
||||||
|
if let Some(handle) = scanline_handle {
|
||||||
|
root.spawn((
|
||||||
|
SplashScanlineOverlay,
|
||||||
|
SplashFadableImage,
|
||||||
|
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()
|
||||||
|
},
|
||||||
|
ImageNode {
|
||||||
|
image: handle,
|
||||||
|
// Start fully transparent so the very first
|
||||||
|
// frame matches every other fadable; the
|
||||||
|
// first `advance_splash` tick lifts this to
|
||||||
|
// `(1, 1, 1, global_alpha)`.
|
||||||
|
color: Color::srgba(1.0, 1.0, 1.0, 0.0),
|
||||||
|
image_mode: NodeImageMode::Tiled {
|
||||||
|
tile_x: true,
|
||||||
|
tile_y: true,
|
||||||
|
stretch_value: 1.0,
|
||||||
|
},
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pure helper — builds the 2×2 source texture for the scanline
|
||||||
|
/// overlay. Top row is fully transparent; bottom row is `#1a1a1a` at
|
||||||
|
/// ~30 % alpha (76 / 255 ≈ 0.298). Tiled across the splash by
|
||||||
|
/// `NodeImageMode::Tiled`, the result is a 2 px-pitch horizontal
|
||||||
|
/// scanline pattern at the alpha called for in the mockup.
|
||||||
|
///
|
||||||
|
/// The tilable unit is 2 px tall (one transparent, one tinted) by
|
||||||
|
/// any width — 2 px wide here is the minimum that still satisfies
|
||||||
|
/// `RenderAssetUsages::RENDER_WORLD`'s validation; the GPU samples
|
||||||
|
/// the same column for every horizontal position.
|
||||||
|
fn build_scanline_image() -> Image {
|
||||||
|
// Per-pixel RGBA bytes. Order is row-major top-to-bottom.
|
||||||
|
let pixels: Vec<u8> = vec![
|
||||||
|
// Row 0: transparent.
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, // Row 1: #1a1a1a at ~30 % alpha (26, 26, 26, 76).
|
||||||
|
26, 26, 26, 76, 26, 26, 26, 76,
|
||||||
|
];
|
||||||
|
// 2 × 2 pixels × 4 bytes per RGBA8 pixel = 16 bytes. Hard-coded
|
||||||
|
// because `TextureFormat::pixel_size()` returns a `Result` in this
|
||||||
|
// Bevy version and a `debug_assert_eq!` shouldn't carry the
|
||||||
|
// unwrap noise.
|
||||||
|
debug_assert_eq!(
|
||||||
|
pixels.len(),
|
||||||
|
16,
|
||||||
|
"scanline pixel buffer must be 2x2 RGBA8",
|
||||||
|
);
|
||||||
|
Image::new(
|
||||||
|
Extent3d {
|
||||||
|
width: 2,
|
||||||
|
height: 2,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
TextureDimension::D2,
|
||||||
|
pixels,
|
||||||
|
TextureFormat::Rgba8UnormSrgb,
|
||||||
|
RenderAssetUsages::RENDER_WORLD,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Header section: cursor block, wordmark, divider, "TERMINAL EDITION"
|
/// Header section: cursor block, wordmark, divider, "TERMINAL EDITION"
|
||||||
/// label. Stacked vertically and centre-aligned. Renders near the top
|
/// label. Stacked vertically and centre-aligned. Renders near the top
|
||||||
/// of the viewport thanks to the root's `justify-between`.
|
/// of the viewport thanks to the root's `justify-between`.
|
||||||
@@ -643,6 +744,7 @@ fn advance_splash(
|
|||||||
mut roots: Query<(Entity, &mut SplashAge, &mut BackgroundColor), With<SplashRoot>>,
|
mut roots: Query<(Entity, &mut SplashAge, &mut BackgroundColor), With<SplashRoot>>,
|
||||||
mut fadable_texts: Query<(&SplashFadable, &mut TextColor)>,
|
mut fadable_texts: Query<(&SplashFadable, &mut TextColor)>,
|
||||||
mut fadable_bgs: Query<(&SplashFadableBg, &mut BackgroundColor), Without<SplashRoot>>,
|
mut fadable_bgs: Query<(&SplashFadableBg, &mut BackgroundColor), Without<SplashRoot>>,
|
||||||
|
mut fadable_images: Query<&mut ImageNode, With<SplashFadableImage>>,
|
||||||
) {
|
) {
|
||||||
for (entity, mut age, mut bg) in &mut roots {
|
for (entity, mut age, mut bg) in &mut roots {
|
||||||
age.0 = age.0.saturating_add(time.delta());
|
age.0 = age.0.saturating_add(time.delta());
|
||||||
@@ -663,6 +765,14 @@ fn advance_splash(
|
|||||||
c.set_alpha(alpha);
|
c.set_alpha(alpha);
|
||||||
bg_color.0 = c;
|
bg_color.0 = c;
|
||||||
}
|
}
|
||||||
|
// ImageNode tints fade by overwriting alpha on a white base so
|
||||||
|
// per-pixel texture transparency (e.g. the 30 %-alpha scanline
|
||||||
|
// rows) survives the multiplication on the GPU.
|
||||||
|
for mut image in &mut fadable_images {
|
||||||
|
let mut c = image.color;
|
||||||
|
c.set_alpha(alpha);
|
||||||
|
image.color = c;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -726,6 +836,13 @@ mod tests {
|
|||||||
app.add_plugins(MinimalPlugins).add_plugins(SplashPlugin);
|
app.add_plugins(MinimalPlugins).add_plugins(SplashPlugin);
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
app.init_resource::<ButtonInput<MouseButton>>();
|
app.init_resource::<ButtonInput<MouseButton>>();
|
||||||
|
// `MinimalPlugins` doesn't pull `AssetPlugin`, so init the
|
||||||
|
// image store explicitly — same pattern as
|
||||||
|
// `settings_plugin::tests`. Without this, `spawn_splash`'s
|
||||||
|
// `Option<ResMut<Assets<Image>>>` falls through and the
|
||||||
|
// scanline overlay is silently skipped, which would defeat
|
||||||
|
// the new tests.
|
||||||
|
app.init_resource::<Assets<Image>>();
|
||||||
app.update();
|
app.update();
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
@@ -1043,6 +1160,76 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pure-helper guard for [`build_scanline_image`]. Asserts the
|
||||||
|
/// generated texture matches the spec literally:
|
||||||
|
///
|
||||||
|
/// * 2 × 2 RGBA8 sRGB.
|
||||||
|
/// * Top row fully transparent (`α = 0`).
|
||||||
|
/// * Bottom row `#1a1a1a` (26, 26, 26) at ~30 % alpha (76 / 255).
|
||||||
|
///
|
||||||
|
/// Locks the bytes so a future tweak to the colour or alpha
|
||||||
|
/// can't silently drift the visible scanline appearance.
|
||||||
|
#[test]
|
||||||
|
fn build_scanline_image_has_expected_2x2_rgba_bytes() {
|
||||||
|
let image = build_scanline_image();
|
||||||
|
let size = image.size();
|
||||||
|
assert_eq!(size.x, 2, "scanline texture width should be 2 px");
|
||||||
|
assert_eq!(size.y, 2, "scanline texture height should be 2 px");
|
||||||
|
|
||||||
|
let bytes = image
|
||||||
|
.data
|
||||||
|
.as_ref()
|
||||||
|
.expect("scanline texture should ship with raw byte data");
|
||||||
|
assert_eq!(
|
||||||
|
bytes.as_slice(),
|
||||||
|
&[
|
||||||
|
0, 0, 0, 0, 0, 0, 0, 0, // top row: transparent
|
||||||
|
26, 26, 26, 76, 26, 26, 26, 76, // bottom row: #1a1a1a @ ~30 % alpha
|
||||||
|
],
|
||||||
|
"scanline pixel buffer drifted from the mockup spec",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// End-to-end: the scanline overlay is spawned as a child of the
|
||||||
|
/// splash root and its `ImageNode.color` tint fades from
|
||||||
|
/// transparent up toward full alpha as `advance_splash` runs.
|
||||||
|
/// Pinning both lets a future regression in either spawn placement
|
||||||
|
/// or the new fade-images branch surface here rather than in a
|
||||||
|
/// visual review.
|
||||||
|
#[test]
|
||||||
|
fn scanline_overlay_spawns_and_fades_with_splash() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
|
||||||
|
let initial_alpha = scanline_tint_alpha(&mut app)
|
||||||
|
.expect("scanline overlay must spawn with the splash root");
|
||||||
|
assert!(
|
||||||
|
initial_alpha <= 0.05,
|
||||||
|
"scanline tint should start near 0; got {initial_alpha}",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Advance past the fade-in window. Tint should now be near 1.
|
||||||
|
let _ = advance_by(&mut app, MOTION_SPLASH_FADE_SECS + 0.4);
|
||||||
|
if count_splash_roots(&mut app) == 0 {
|
||||||
|
return; // already past fade-out under the test clock — skip.
|
||||||
|
}
|
||||||
|
let mid_alpha = scanline_tint_alpha(&mut app)
|
||||||
|
.expect("scanline overlay should still exist during the hold");
|
||||||
|
assert!(
|
||||||
|
mid_alpha >= 0.9,
|
||||||
|
"scanline tint should reach full alpha during the hold; got {mid_alpha}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the unique scanline overlay's `ImageNode.color` tint
|
||||||
|
/// alpha. Returns `None` if the overlay isn't in the world (e.g.
|
||||||
|
/// the splash already despawned, or this tick is pre-spawn).
|
||||||
|
fn scanline_tint_alpha(app: &mut App) -> Option<f32> {
|
||||||
|
let mut q = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&ImageNode, With<SplashScanlineOverlay>>();
|
||||||
|
q.iter(app.world()).next().map(|img| img.color.alpha())
|
||||||
|
}
|
||||||
|
|
||||||
/// Pure-helper guard. The pulse factor is a sine wave shifted into
|
/// Pure-helper guard. The pulse factor is a sine wave shifted into
|
||||||
/// `[min..1.0]`. Three corner cases are pinned:
|
/// `[min..1.0]`. Three corner cases are pinned:
|
||||||
///
|
///
|
||||||
|
|||||||
Reference in New Issue
Block a user