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:
funman300
2026-05-07 22:42:54 -07:00
parent 29136d815d
commit a27cf5a020
+187
View File
@@ -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:
/// ///