use super::image_sprite;
use super::statics::BoardStatic;
use super::z;
use crate::app::Sim;
use crate::app::art::Art;
use crate::app::layout::{self, TILE};
use crate::app::materials::{WaterMaterial, WaterMode, WaterUniform};
use crate::sim::Board;
use bevy::prelude::*;
const CLOUD_REACH: f32 = 2400.0;
const REACH: f32 = 9000.0;
#[derive(Component)]
pub struct CloudShadow {
speed: f32,
edge: f32,
}
pub(super) fn spawn_weather(commands: &mut Commands, board: &Board, art: &Art) {
let Vec2 { x: w, y: h } = layout::board_extent(board);
commands.spawn((
BoardStatic,
image_sprite(&art.vignette, Color::WHITE, Vec2::new(w + 40.0, h + 40.0)),
Transform::from_translation(Vec3::new(0.0, 0.0, z::POOL - 0.05)),
));
let edge = w / 2.0 + CLOUD_REACH;
for (i, (span, speed, at)) in [(3.4, 9.0, -0.28), (5.0, -6.0, 0.12), (2.6, 13.0, 0.38)]
.into_iter()
.enumerate()
{
commands.spawn((
BoardStatic,
CloudShadow { speed, edge },
image_sprite(
&art.cloud,
Color::srgba(0.10, 0.13, 0.20, 0.10),
Vec2::new(span * TILE, span * TILE * 0.62),
),
Transform::from_translation(Vec3::new(
-w / 2.0 + (i as f32 + 0.5) / 3.0 * w,
at * h,
z::POOL - 0.08,
)),
));
}
}
pub fn drift_cloud_shadows(
sim: Res<Sim>,
time: Res<Time>,
settings: Res<crate::app::settings::GameSettings>,
round: crate::app::round_state::RoundState,
mut clouds: Query<(&CloudShadow, &mut Transform)>,
) {
if settings.reduced_motion || !round.moving(&sim.0) {
return;
}
let dt = time.delta_secs();
for (cloud, mut transform) in &mut clouds {
transform.translation.x += cloud.speed * dt;
if transform.translation.x > cloud.edge {
transform.translation.x = -cloud.edge;
} else if transform.translation.x < -cloud.edge {
transform.translation.x = cloud.edge;
}
}
}
pub(super) fn spawn_beach(commands: &mut Commands, board: &Board, art: &Art) {
for (center, size) in beach_strips(layout::board_extent(board)) {
commands.spawn((
BoardStatic,
art.shaded.quad(
&art.shaded.sand,
Transform::from_translation(center.extend(z::SAND - 10.0)),
size,
),
));
}
commands.spawn((
BoardStatic,
Sea,
art.shaded.quad(
&art.shaded.sea,
Transform::from_translation(Vec3::new(0.0, 0.0, SEA_Z)),
Vec2::ONE,
),
Visibility::Hidden,
));
furnish_the_margin(commands, board, art);
}
const BEACH_TUCK: f32 = 4.0;
fn beach_strips(extent: Vec2) -> [(Vec2, Vec2); 4] {
let inner = extent / 2.0 - Vec2::splat(BEACH_TUCK);
let outer = REACH / 2.0;
let side = outer - inner.x;
let cap = outer - inner.y;
let x = (inner.x + outer) / 2.0;
let y = (inner.y + outer) / 2.0;
[
(Vec2::new(-x, 0.0), Vec2::new(side, REACH)),
(Vec2::new(x, 0.0), Vec2::new(side, REACH)),
(Vec2::new(0.0, y), Vec2::new(2.0 * inner.x, cap)),
(Vec2::new(0.0, -y), Vec2::new(2.0 * inner.x, cap)),
]
}
#[derive(Component)]
pub struct Sea;
const SEA_Z: f32 = z::SAND - 9.0;
const FRAME_CLEAR: f32 = 22.0;
const SEA_VIEW: f32 = 64.0;
const SEA_WET: f32 = 18.0;
const SEA_DEEPENS: f32 = 110.0;
const SEA_SHALLOW: Color = Color::srgba(0.47, 0.82, 0.80, 0.92);
const SEA_DEEP: Color = Color::srgba(0.12, 0.42, 0.60, 1.0);
pub fn sea_uniform() -> WaterUniform {
let mut water = WaterUniform::new(WaterMode::Sea, SEA_SHALLOW, SEA_DEEP);
water.wet = SEA_WET;
water.depth_span = SEA_DEEPENS;
water
}
fn sea_span(
bottom: f32,
scale: f32,
ui: f32,
board_bottom: f32,
progress: f32,
swell: f32,
) -> Option<(f32, f32)> {
let px = ui * scale;
let cap = board_bottom - FRAME_CLEAR;
let rise = super::tide::RISE * px;
let reach = super::tide::SWELL * px;
let floor = bottom + SEA_WET + 2.0 * px;
let start = (bottom + SEA_VIEW * px).min(cap - rise - reach).max(floor);
let climb = rise.min((cap - reach - start).max(0.0));
let resting = (start + climb * progress).min(cap);
if resting <= bottom + SEA_WET {
return None;
}
let top = (resting + swell * px).min(cap);
Some((top, top - (bottom - 40.0 * scale)))
}
type SeaQuad = (&'static mut Transform, &'static mut Visibility);
#[derive(bevy::ecs::system::SystemParam)]
pub struct SeaView<'w, 's> {
ui_scale: Res<'w, UiScale>,
windows: Query<'w, 's, &'static Window>,
cameras: Query<
'w,
's,
(&'static Projection, &'static Transform),
crate::app::match_setup::GameCamera,
>,
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct SeaRound<'w> {
settings: Res<'w, crate::app::settings::GameSettings>,
round: crate::app::round_state::RoundState<'w>,
clock: Res<'w, crate::app::round_state::RoundClock>,
}
pub fn lay_the_sea(
sim: Res<Sim>,
art: Res<Art>,
view: SeaView,
round: SeaRound,
mut materials: ResMut<Assets<WaterMaterial>>,
mut sea: Query<SeaQuad, (With<Sea>, Without<Camera2d>)>,
) {
let Ok((mut transform, mut visibility)) = sea.single_mut() else {
return;
};
let (Ok(window), Ok((Projection::Orthographic(ortho), camera))) =
(view.windows.single(), view.cameras.single())
else {
return;
};
let (ui, scale) = (view.ui_scale.0, ortho.scale);
let bottom = camera.translation.y - window.height() / 2.0 * scale;
let board_bottom = -layout::board_extent(&sim.0).y / 2.0;
let (progress, swell) = super::tide::climb(
&sim.0,
round.round.countdown(&sim.0),
round.clock.0,
round.settings.reduced_motion,
);
let Some((top, height)) = sea_span(bottom, scale, ui, board_bottom, progress, swell) else {
if *visibility != Visibility::Hidden {
*visibility = Visibility::Hidden;
}
return;
};
if *visibility != Visibility::Inherited {
*visibility = Visibility::Inherited;
}
let width = (window.width() + 200.0) * scale;
let at = Vec3::new(camera.translation.x, top - height / 2.0, SEA_Z);
let size = Vec3::new(width, height, 1.0);
if transform.translation != at || transform.scale != size {
transform.translation = at;
transform.scale = size;
}
let tint = (!round.round.ended(&sim.0))
.then(|| super::tide::mania_tint(&sim.0))
.flatten()
.map_or(LinearRgba::NONE, LinearRgba::from);
let stale = materials
.get(&art.shaded.sea)
.is_some_and(|m| m.water.tint != tint);
if stale && let Some(mut material) = materials.get_mut(&art.shaded.sea) {
material.water.tint = tint;
}
}
const MARGIN_TILES: f32 = 14.0;
fn margin_spot(rng: &mut crate::app::effects::VisualRng, size: Vec2) -> Option<Vec2> {
let clear = size / 2.0 + Vec2::splat(super::RIM + 14.0);
let reach = size / 2.0 + Vec2::splat(MARGIN_TILES * TILE);
let at = Vec2::new(rng.range(-reach.x, reach.x), rng.range(-reach.y, reach.y));
(at.x.abs() > clear.x || at.y.abs() > clear.y).then_some(at)
}
fn margin_seed(board: &Board) -> u32 {
(board.seed() as u32)
^ u32::from(board.width()).wrapping_mul(0x9E37_79B1)
^ u32::from(board.height()).wrapping_mul(0x85EB_CA77)
}
fn furnish_the_margin(commands: &mut Commands, board: &Board, art: &Art) {
let size = layout::board_extent(board);
let mut rng = crate::app::effects::VisualRng::seeded(margin_seed(board));
let spot = |rng: &mut crate::app::effects::VisualRng| margin_spot(rng, size);
let z = z::SAND - 9.5;
for _ in 0..140 {
let Some(at) = spot(&mut rng) else { continue };
let (image, tint, size, turn) = match rng.next() % 10 {
0..=3 => (
&art.kelp,
Color::srgba(0.62, 0.70, 0.45, 0.80),
Vec2::splat(TILE * rng.range(0.38, 0.62)),
rng.range(-0.25, 0.25),
),
4..=5 => (
&art.log,
Color::srgba(0.92, 0.86, 0.78, 0.90),
Vec2::splat(TILE * rng.range(0.55, 0.85)),
rng.range(0.0, std::f32::consts::TAU),
),
6..=8 => (
&art.shadow,
Color::srgba(0.42, 0.40, 0.37, 0.55),
Vec2::new(TILE * 0.16, TILE * 0.11) * rng.range(0.7, 1.4),
rng.range(0.0, std::f32::consts::TAU),
),
_ => (
&art.star,
Color::srgba(0.93, 0.56, 0.42, 0.85),
Vec2::splat(TILE * rng.range(0.20, 0.28)),
rng.range(0.0, std::f32::consts::TAU),
),
};
commands.spawn((
BoardStatic,
image_sprite(&art.shadow, Color::srgba(1.0, 1.0, 1.0, 0.35), size * 0.9),
Transform::from_translation((at + layout::SUN).extend(z + 0.01)),
));
commands.spawn((
BoardStatic,
image_sprite(image, tint, size),
Transform::from_translation(at.extend(z + 0.02))
.with_rotation(Quat::from_rotation_z(turn)),
));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_beach_frames_the_board_without_covering_it() {
let extent = Vec2::new(16.0 * TILE, 11.0 * TILE);
let strips = beach_strips(extent);
let under = |p: Vec2| {
strips
.iter()
.filter(|(c, size)| (p - *c).abs().cmplt(*size / 2.0).all())
.count()
};
let half = extent / 2.0;
let probes = [
Vec2::new(half.x + 1.0, 0.0),
Vec2::new(-half.x - 1.0, half.y + 1.0),
Vec2::new(0.0, -half.y - 1.0),
Vec2::new(0.0, half.y - 1.0),
Vec2::new(half.x - 1.0, -half.y + 1.0),
Vec2::new(REACH / 2.0 - 1.0, -REACH / 2.0 + 1.0),
Vec2::new(-REACH / 2.0 + 1.0, 0.0),
];
for p in probes {
assert_eq!(under(p), 1, "{p} is under {} strips", under(p));
}
assert_eq!(under(Vec2::ZERO), 0);
assert_eq!(under(half - Vec2::splat(BEACH_TUCK + 1.0)), 0);
}
#[test]
fn the_sea_keeps_to_the_bottom_of_the_view_and_off_the_board() {
let board_bottom = -300.0;
let clear = board_bottom - FRAME_CLEAR;
let (top, height) = sea_span(-700.0, 1.0, 1.0, board_bottom, 0.0, 0.0).expect("room");
assert_eq!(top, -700.0 + SEA_VIEW);
assert!(height > SEA_VIEW, "runs on past the bottom: {height}");
let (top, _) = sea_span(-380.0, 1.0, 1.0, board_bottom, 0.0, 0.0).expect("a little");
assert!(top <= clear, "{top} reaches the frame at {clear}");
assert_eq!(sea_span(-320.0, 1.0, 1.0, board_bottom, 0.0, 0.0), None);
let (top, _) = sea_span(-2000.0, 2.0, 1.0, board_bottom, 0.0, 0.0).expect("room");
assert_eq!(top, -2000.0 + SEA_VIEW * 2.0);
let (top, _) = sea_span(-2000.0, 1.0, 1.5, board_bottom, 0.0, 0.0).expect("room");
assert_eq!(top, -2000.0 + SEA_VIEW * 1.5);
let rise = super::super::tide::RISE;
let (top, _) = sea_span(-2000.0, 1.0, 1.0, board_bottom, 1.0, 0.0).expect("room");
assert_eq!(top, -2000.0 + SEA_VIEW + rise);
let (top, _) = sea_span(-380.0, 1.0, 1.0, board_bottom, 1.0, 50.0).expect("room");
assert!(top <= clear, "{top} swelled onto the frame at {clear}");
}
#[test]
fn a_tight_window_still_tells_the_whole_round() {
let board_bottom = -300.0;
let cap = board_bottom - FRAME_CLEAR;
let reach = super::super::tide::SWELL;
let bottom = cap - SEA_VIEW - 20.0;
let at = |progress, swell| {
sea_span(bottom, 1.0, 1.0, board_bottom, progress, swell)
.unwrap()
.0
};
assert!(
at(0.5, 0.0) < at(0.9, 0.0),
"still climbing late in the round"
);
assert!(at(0.9, 0.0) < at(1.0, 0.0), "and right up to its end");
assert!(
(at(1.0, 0.0) - (cap - reach)).abs() < 1e-3,
"short of the frame by a swell"
);
let (up, down) = (
at(1.0, reach) - at(1.0, 0.0),
at(1.0, 0.0) - at(1.0, -reach),
);
assert!(
(up - down).abs() < 1e-3,
"a whole swell at the end: {up} up, {down} down"
);
}
#[test]
fn a_crowded_window_still_moves_the_clock() {
let board_bottom = -300.0;
let cap = board_bottom - FRAME_CLEAR;
let bottom = cap - SEA_VIEW;
let at = |progress| {
sea_span(bottom, 1.0, 1.0, board_bottom, progress, 0.0)
.unwrap()
.0
};
assert!(
at(1.0) - at(0.0) > 10.0,
"it climbs: {} to {}",
at(0.0),
at(1.0)
);
assert!(
at(0.0) > bottom + SEA_WET,
"and starts where it can be seen"
);
}
#[test]
fn a_swell_never_hides_the_sea() {
let board_bottom = -300.0;
let cap = board_bottom - FRAME_CLEAR;
let reach = super::super::tide::SWELL;
let bottom = cap - SEA_WET - 3.0;
let shown = sea_span(bottom, 1.0, 1.0, board_bottom, 0.0, 0.0).is_some();
for swell in [-reach, -reach / 2.0, reach / 2.0, reach] {
assert_eq!(
sea_span(bottom, 1.0, 1.0, board_bottom, 0.0, swell).is_some(),
shown,
"a swell of {swell} changed whether the sea shows"
);
}
}
#[test]
fn a_level_keeps_its_own_beach() {
let seed = |w, h, s| margin_seed(&Board::new(w, h, s));
assert_eq!(seed(12, 9, 7), seed(12, 9, 7));
assert_ne!(seed(12, 9, 7), seed(12, 9, 8), "another seed");
assert_ne!(seed(12, 9, 7), seed(9, 12, 7), "the same area turned round");
}
#[test]
fn the_margin_keeps_off_the_board() {
let size = Vec2::new(12.0, 9.0) * TILE;
let mut rng = crate::app::effects::VisualRng::seeded(7);
let mut placed = 0;
for _ in 0..5000 {
let Some(at) = margin_spot(&mut rng, size) else {
continue;
};
placed += 1;
let off_board = at.x.abs() > size.x / 2.0 + super::super::RIM
|| at.y.abs() > size.y / 2.0 + super::super::RIM;
assert!(off_board, "{at} is on the board or its rim");
}
assert!(placed > 1000, "only {placed} of 5000 found a place");
}
}