use super::VisualRng;
use crate::app::art;
use crate::app::palette;
use crate::sim::CrabKind;
use bevy::prelude::*;
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum CritterKind {
Crab,
Gull,
Cloud,
Boat,
}
#[derive(Component)]
pub struct MenuCritter {
speed: f32,
frame_clock: f32,
kind: CritterKind,
base_y: f32,
}
#[derive(Component)]
pub struct WaveSegment {
base: Vec2,
amp: f32,
freq: f32,
speed: f32,
phase: f32,
brightness: f32,
}
#[derive(Component)]
pub struct ShoreFoam {
base_y: f32,
}
const WAVE_LANES: usize = 7;
const WAVE_SEGMENTS: usize = 110;
const SHORE_H: f32 = 208.0;
const HORIZON: f32 = 0.62;
#[derive(Component)]
pub struct MenuShore;
#[derive(Component)]
pub struct Scrim;
fn scrim_size(size: Vec2) -> Vec2 {
size + Vec2::splat(80.0)
}
pub fn tend_backdrop(
mut commands: Commands,
screen: Res<State<crate::app::Screen>>,
viewport: crate::app::menu_ui::Viewport,
shore: Query<Entity, With<MenuShore>>,
critters: Query<Entity, With<MenuCritter>>,
scrim: Query<Entity, With<Scrim>>,
) {
let on_postcard = crate::app::postcard_screen(*screen.get());
if !on_postcard {
for entity in shore.iter().chain(critters.iter()).chain(scrim.iter()) {
commands.entity(entity).despawn();
}
return;
}
let wants_scrim = *screen.get() != crate::app::Screen::Menu;
if wants_scrim && scrim.is_empty() {
let Some(size) = viewport.size() else {
return;
};
commands.spawn((
Scrim,
Sprite::from_color(Color::srgba(0.07, 0.09, 0.12, 0.55), scrim_size(size)),
Transform::from_translation(Vec3::new(0.0, 0.0, 5.0)),
));
} else if !wants_scrim {
for entity in &scrim {
commands.entity(entity).despawn();
}
}
}
fn spawn_shore(commands: &mut Commands, art: &art::Art, rng: &mut VisualRng, size: Vec2) {
let (w, h) = (size.x, size.y);
let bottom = -h / 2.0;
let horizon = bottom + h * HORIZON;
let band = |y: f32, height: f32, color: Color, z: f32| {
(
MenuShore,
Sprite::from_color(color, Vec2::new(w + 40.0, height)),
Transform::from_translation(Vec3::new(0.0, y, z)),
)
};
let wash = |y: f32, height: f32, color: Color, z: f32, upward: bool| {
(
MenuShore,
Sprite {
image: art.ramp.clone(),
color,
custom_size: Some(Vec2::new(w + 40.0, height)),
..default()
},
Transform::from_translation(Vec3::new(0.0, y, z)).with_rotation(if upward {
Quat::from_rotation_z(std::f32::consts::PI)
} else {
Quat::IDENTITY
}),
)
};
let sky_h = h / 2.0 - horizon;
let sky_mid = horizon + sky_h / 2.0;
commands.spawn(band(sky_mid, sky_h, Color::srgb(0.62, 0.79, 0.90), 0.0));
commands.spawn(wash(
sky_mid,
sky_h,
Color::srgb(0.42, 0.65, 0.85),
0.05,
false,
));
let sea_top = SHORE_H + 18.0;
let sea_h = (horizon - bottom) - sea_top;
let sea_mid = horizon - sea_h / 2.0;
commands.spawn(band(sea_mid, sea_h, Color::srgb(0.16, 0.36, 0.52), 0.0));
commands.spawn(wash(
sea_mid,
sea_h,
Color::srgb(0.27, 0.51, 0.63),
0.02,
true,
));
commands.spawn(band(horizon, 3.0, Color::srgb(0.72, 0.84, 0.92), 0.1));
commands.spawn(band(
bottom + SHORE_H / 2.0,
SHORE_H,
Color::srgb(0.86, 0.77, 0.57),
0.0,
));
commands.spawn(wash(
bottom + SHORE_H / 2.0,
SHORE_H,
Color::srgba(0.70, 0.62, 0.45, 0.65),
0.02,
false,
));
commands.spawn(band(
bottom + SHORE_H + 9.0,
18.0,
Color::srgb(0.72, 0.64, 0.47),
0.0,
));
let foam_y = bottom + SHORE_H + 20.0;
commands.spawn((
MenuShore,
ShoreFoam { base_y: foam_y },
Sprite {
image: art.foam.clone(),
color: Color::srgba(1.0, 1.0, 1.0, 0.65),
custom_size: Some(Vec2::new(w + 40.0, 12.0)),
..default()
},
Transform::from_translation(Vec3::new(0.0, foam_y, 0.1)),
));
spawn_swell(commands, rng, w, sea_top, sea_h, bottom);
seed_travellers(commands, art, rng, w, h);
spawn_beach_props(commands, art, rng, w, bottom);
}
struct Prop {
image: Handle<Image>,
tint: Color,
size: Vec2,
shadow: f32,
rotation: f32,
paint: Option<(Handle<Image>, Color)>,
}
fn spawn_swell(
commands: &mut Commands,
rng: &mut VisualRng,
w: f32,
sea_top: f32,
sea_h: f32,
bottom: f32,
) {
let seg_w = (w + 40.0) / WAVE_SEGMENTS as f32;
for lane in 0..WAVE_LANES {
let near = (lane as f32 + 0.5) / WAVE_LANES as f32;
let y = bottom + sea_top + sea_h * (1.0 - near) * 0.98;
let amp = 1.5 + near * near * 9.0;
let freq = 0.020 + near * 0.055;
let speed = 0.6 + near * 2.2;
let phase = rng.range(0.0, std::f32::consts::TAU);
let brightness = 0.10 + near * 0.42;
for segment in 0..WAVE_SEGMENTS {
let x = -w / 2.0 - 20.0 + seg_w * (segment as f32 + 0.5);
let base = Vec2::new(x, y);
commands.spawn((
MenuShore,
WaveSegment {
base,
amp,
freq,
speed,
phase,
brightness,
},
Sprite::from_color(
Color::srgba(1.0, 1.0, 1.0, 0.0),
Vec2::new(seg_w + 1.5, 2.0 + near * 3.0),
),
Transform::from_translation(base.extend(0.2)),
));
}
}
}
fn seed_travellers(commands: &mut Commands, art: &art::Art, rng: &mut VisualRng, w: f32, h: f32) {
for _ in 0..3 {
let x = rng.range(-w * 0.45, w * 0.45);
spawn_critter(commands, art, rng, w, h, CritterKind::Cloud, Some(x));
}
let boat_x = rng.range(-w * 0.35, w * 0.35);
spawn_critter(commands, art, rng, w, h, CritterKind::Boat, Some(boat_x));
let crab_x = rng.range(-w * 0.4, w * 0.4);
spawn_critter(commands, art, rng, w, h, CritterKind::Crab, Some(crab_x));
}
fn roll_prop(art: &art::Art, rng: &mut VisualRng, scale: f32) -> Prop {
match rng.next() % 12 {
0 | 1 => Prop {
image: art.castle.clone(),
tint: crate::app::board_render::castle_body(palette::player_color(
(rng.next() % 4) as u8,
)),
size: Vec2::splat(52.0 * scale),
shadow: 44.0 * scale,
rotation: 0.0,
paint: None,
},
2 => Prop {
image: art.log.clone(),
tint: Color::srgb(1.15, 1.12, 1.05),
size: Vec2::splat(50.0 * scale),
shadow: 40.0 * scale,
rotation: if rng.next().is_multiple_of(2) {
0.0
} else {
std::f32::consts::FRAC_PI_2
},
paint: None,
},
3 | 4 => Prop {
image: art.kelp.clone(),
tint: Color::srgba(1.0, 1.0, 1.0, 0.92),
size: Vec2::splat(rng.range(26.0, 38.0) * scale),
shadow: 0.0,
rotation: rng.range(-0.25, 0.25),
paint: None,
},
5 => Prop {
image: art.pool.clone(),
tint: Color::srgba(1.0, 1.0, 1.0, 0.9),
size: Vec2::splat(rng.range(46.0, 62.0) * scale),
shadow: 0.0,
rotation: 0.0,
paint: None,
},
6 => Prop {
image: art.hole.clone(),
tint: Color::WHITE,
size: Vec2::splat(rng.range(26.0, 34.0) * scale),
shadow: 0.0,
rotation: 0.0,
paint: None,
},
7 => Prop {
image: art.sign_board.clone(),
tint: Color::WHITE,
paint: Some((
art.sign_paint.clone(),
palette::player_color((rng.next() % 4) as u8).lighter(0.08),
)),
size: Vec2::splat(rng.range(30.0, 38.0) * scale),
shadow: 24.0 * scale,
rotation: (rng.next() % 4) as f32 * std::f32::consts::FRAC_PI_2,
},
8..=10 => Prop {
image: art.rock.clone(),
tint: Color::WHITE,
size: Vec2::splat(rng.range(22.0, 38.0) * scale),
shadow: 22.0 * scale,
rotation: rng.range(0.0, std::f32::consts::TAU),
paint: None,
},
_ => Prop {
image: art.star.clone(),
tint: Color::srgba(0.95, 0.78, 0.6, 0.85),
size: Vec2::splat(rng.range(9.0, 15.0) * scale),
shadow: 0.0,
rotation: rng.range(0.0, std::f32::consts::TAU),
paint: None,
},
}
}
fn spawn_beach_props(
commands: &mut Commands,
art: &art::Art,
rng: &mut VisualRng,
w: f32,
bottom: f32,
) {
let slots = ((w / 58.0) as usize).clamp(10, 26);
for slot in 0..slots {
let lane = (slot as f32 + 0.5) / slots as f32;
let x = (lane - 0.5) * w + rng.range(-38.0, 38.0);
let depth = rng.range(0.0, 1.0);
let y = bottom + 88.0 + depth * (SHORE_H - 104.0);
let scale = 1.0 - depth * 0.35;
let prop = roll_prop(art, rng, scale);
let z = 0.6 - depth * 0.25;
if prop.shadow > 0.0 {
commands.spawn((
MenuShore,
Sprite {
image: art.shadow.clone(),
color: Color::srgba(0.36, 0.28, 0.18, 0.28),
custom_size: Some(Vec2::new(prop.shadow, prop.shadow * 0.42)),
..default()
},
Transform::from_translation(Vec3::new(
x + prop.shadow * 0.12,
y - prop.size.y * 0.36,
z - 0.01,
)),
));
}
let mut piece = commands.spawn((
MenuShore,
Sprite {
image: prop.image,
color: prop.tint,
custom_size: Some(prop.size),
..default()
},
Transform::from_translation(Vec3::new(x, y, z))
.with_rotation(Quat::from_rotation_z(prop.rotation)),
));
if let Some((image, color)) = prop.paint {
piece.with_child((
Sprite {
image,
color,
custom_size: Some(prop.size),
..default()
},
Transform::from_translation(Vec3::new(0.0, 0.0, 0.001)),
));
}
}
for _ in 0..90 {
let pale = rng.next().is_multiple_of(3);
let tint = if pale {
Color::srgba(1.0, 0.97, 0.88, 0.30)
} else {
Color::srgba(0.55, 0.46, 0.31, 0.22)
};
let size = rng.range(2.5, 7.0);
commands.spawn((
MenuShore,
Sprite::from_color(tint, Vec2::splat(size)),
Transform::from_translation(Vec3::new(
rng.range(-w / 2.0, w / 2.0),
rng.range(bottom + 4.0, bottom + SHORE_H - 6.0),
0.12,
)),
));
}
for _ in 0..5 {
commands.spawn((
MenuShore,
Sprite {
image: art.foam.clone(),
color: Color::srgba(0.55, 0.47, 0.34, 0.22),
custom_size: Some(Vec2::new(rng.range(120.0, 240.0), rng.range(10.0, 18.0))),
..default()
},
Transform::from_translation(Vec3::new(
rng.range(-w / 2.0, w / 2.0),
bottom + SHORE_H - rng.range(6.0, 34.0),
0.15,
)),
));
}
}
type PostcardPieces = Or<(With<MenuShore>, With<MenuCritter>)>;
pub fn refit_shore(
mut commands: Commands,
art: Res<art::Art>,
mut rng: ResMut<VisualRng>,
viewport: crate::app::menu_ui::Viewport,
mut last: Local<Vec2>,
scene: Query<(Entity, Has<MenuShore>), PostcardPieces>,
mut scrim: Query<&mut Sprite, With<Scrim>>,
) {
let Some(size) = viewport.size() else {
return;
};
if *last == size && scene.iter().any(|(_, shore)| shore) {
return;
}
*last = size;
for (entity, _) in &scene {
commands.entity(entity).despawn();
}
spawn_shore(&mut commands, &art, &mut rng, size);
for mut sprite in &mut scrim {
sprite.custom_size = Some(scrim_size(size));
}
}
fn spawn_critter(
commands: &mut Commands,
art: &art::Art,
rng: &mut VisualRng,
w: f32,
h: f32,
kind: CritterKind,
at_x: Option<f32>,
) {
let bottom = -h / 2.0;
let horizon = bottom + h * HORIZON;
let rightward = rng.next().is_multiple_of(2);
let (y, speed, image, size, tint) = match kind {
CritterKind::Gull => (
rng.range(horizon + 30.0, h / 2.0 - 40.0),
rng.range(130.0, 190.0),
art.gull_fly.clone(),
Vec2::splat(46.0),
Color::WHITE,
),
CritterKind::Cloud => (
rng.range(horizon + h * 0.08, h / 2.0 - 30.0),
rng.range(9.0, 20.0),
art.cloud.clone(),
Vec2::new(rng.range(110.0, 190.0), rng.range(55.0, 95.0)),
Color::srgba(1.0, 1.0, 1.0, rng.range(0.75, 0.95)),
),
CritterKind::Boat => (
rng.range(horizon - 60.0, horizon - 14.0),
rng.range(18.0, 30.0),
art.boat.clone(),
Vec2::splat(rng.range(38.0, 54.0)),
Color::WHITE,
),
CritterKind::Crab => {
const KINDS: [CrabKind; 6] = [
CrabKind::Common,
CrabKind::Common,
CrabKind::Juvenile,
CrabKind::Giant,
CrabKind::Molting,
CrabKind::Golden,
];
let crab = KINDS[(rng.next() % 6) as usize];
(
rng.range(bottom + 18.0, bottom + SHORE_H - 40.0),
rng.range(28.0, 55.0),
art.crab.clone(),
Vec2::splat(30.0),
crate::app::creatures::body_color(crab),
)
}
};
let speed = if rightward { speed } else { -speed };
let rotation = match kind {
CritterKind::Crab | CritterKind::Gull => crate::app::layout::dir_rotation(if rightward {
crate::sim::Direction::Right
} else {
crate::sim::Direction::Left
}),
CritterKind::Cloud | CritterKind::Boat => Quat::IDENTITY,
};
let z = match kind {
CritterKind::Cloud => 1.5,
CritterKind::Gull => 2.0,
CritterKind::Boat => 0.3,
CritterKind::Crab => 0.5,
};
let x = at_x.unwrap_or(-speed.signum() * (w / 2.0 + 110.0));
let handed = if rng.next().is_multiple_of(2) {
crate::sim::Handedness::Left
} else {
crate::sim::Handedness::Right
};
let flip_y = kind == CritterKind::Crab && crate::app::creatures::mirrored(handed);
let mut critter = commands.spawn((
MenuCritter {
speed,
frame_clock: rng.range(0.0, 3.0),
kind,
base_y: y,
},
Sprite {
image,
color: tint,
custom_size: Some(size),
flip_x: kind == CritterKind::Boat && !rightward,
flip_y,
..default()
},
Transform::from_translation(Vec3::new(x, y, z)).with_rotation(rotation),
));
if kind == CritterKind::Crab {
critter.with_child((
Sprite {
image: art.claw.clone(),
color: crate::app::creatures::claw_color(handed),
custom_size: Some(size),
flip_y,
..default()
},
Transform::from_translation(Vec3::new(0.0, 0.0, 0.01)),
));
}
}
type SeaPart<Marker> = (&'static Marker, &'static mut Transform, &'static mut Sprite);
#[derive(bevy::ecs::system::SystemParam)]
pub struct Movers<'w, 's> {
critters: Query<
'w,
's,
(
Entity,
&'static mut MenuCritter,
&'static mut Transform,
&'static mut Sprite,
),
>,
waves: Query<'w, 's, SeaPart<WaveSegment>, Without<MenuCritter>>,
foam: Query<'w, 's, SeaPart<ShoreFoam>, (Without<MenuCritter>, Without<WaveSegment>)>,
}
pub fn menu_ambience(
mut commands: Commands,
time: Res<Time>,
art: Res<art::Art>,
mut rng: ResMut<VisualRng>,
mut countdown: Local<f32>,
viewport: crate::app::menu_ui::Viewport,
movers: Movers,
) {
let Movers {
mut critters,
mut waves,
mut foam,
} = movers;
let Some(size) = viewport.size() else {
return;
};
let (w, h) = (size.x, size.y);
let dt = time.delta_secs();
*countdown -= dt;
if *countdown <= 0.0 {
*countdown = rng.range(2.0, 5.0);
let kind = match rng.next() % 10 {
0..=2 => CritterKind::Gull,
3..=4 => CritterKind::Cloud,
5 => CritterKind::Boat,
_ => CritterKind::Crab,
};
spawn_critter(&mut commands, &art, &mut rng, w, h, kind, None);
}
for (entity, mut critter, mut transform, mut sprite) in &mut critters {
transform.translation.x += critter.speed * dt;
if transform.translation.x.abs() > w / 2.0 + 130.0 {
commands.entity(entity).despawn();
continue;
}
critter.frame_clock += dt;
match critter.kind {
CritterKind::Crab => {
let frame_b = (critter.frame_clock / 0.16) as u32 % 2 == 1;
let want = if frame_b { &art.crab_b } else { &art.crab };
if sprite.image != *want {
sprite.image = want.clone();
}
}
CritterKind::Boat => {
transform.translation.y = critter.base_y + (critter.frame_clock * 1.6).sin() * 2.5;
}
CritterKind::Gull | CritterKind::Cloud => {}
}
}
let now = time.elapsed_secs();
for (wave, mut transform, mut sprite) in &mut waves {
let angle = wave.base.x * wave.freq - now * wave.speed + wave.phase;
let long = (angle * 0.37 - now * wave.speed * 0.55).sin();
let swell = (angle.sin() + long * 0.55) / 1.55;
transform.translation.y = wave.base.y + swell * wave.amp;
let crest = ((swell + 1.0) * 0.5).powf(2.2);
sprite.color.set_alpha(wave.brightness * crest);
}
for (shore, mut transform, mut sprite) in &mut foam {
let breath = (now * 0.42).sin();
transform.translation.y = shore.base_y + breath * 5.0;
sprite.color.set_alpha(0.5 + 0.22 * (breath * 0.5 + 0.5));
}
}