use super::{image_sprite, on_board, z};
use crate::app::Sim;
use crate::app::art::Art;
use crate::app::layout::{self, TILE};
use crate::app::palette;
use crate::sim::{Board, Direction, SignpostHealth, TileKind};
use bevy::prelude::*;
#[derive(Component)]
pub struct SignpostSprite {
x: u8,
y: u8,
dir: Direction,
owner: u8,
age: f32,
planted: u64,
}
#[derive(Component)]
pub struct SignpostShadow;
const PLANT_POP: f32 = 0.17;
fn plant_pop(age: f32) -> f32 {
if age >= PLANT_POP {
return 1.0;
}
let t = age / PLANT_POP;
1.0 + 0.35 * (1.0 - t) * (t * std::f32::consts::PI * 1.5).cos()
}
fn post_alpha(fade: f32) -> f32 {
0.55 + 0.45 * fade
}
fn wither(fade: f32) -> f32 {
0.86 + 0.14 * fade
}
#[derive(Component)]
pub struct BoardStatic;
fn spawn_sand(commands: &mut Commands, art: &Art, pos: Vec2, x: u8, y: u8) {
let sand = if (x + y).is_multiple_of(2) {
&art.sand_a
} else {
&art.sand_b
};
let quarter_turns = f32::from((x.wrapping_mul(7).wrapping_add(y.wrapping_mul(13))) % 4);
commands.spawn((
BoardStatic,
image_sprite(sand, Color::WHITE, Vec2::splat(TILE)),
Transform::from_translation(pos.extend(z::SAND)).with_rotation(Quat::from_rotation_z(
quarter_turns * std::f32::consts::FRAC_PI_2,
)),
));
}
fn ground_shadow(commands: &mut Commands, art: &Art, pos: Vec2, size: f32) {
commands.spawn((
BoardStatic,
image_sprite(
&art.shadow,
Color::srgba(1.0, 1.0, 1.0, 0.75),
Vec2::new(size, size * 0.82),
),
Transform::from_translation((pos + layout::SUN).extend(z::TILE_FEATURE - 0.05)),
));
}
fn spawn_tile_feature(commands: &mut Commands, art: &Art, pos: Vec2, kind: TileKind) {
match kind {
TileKind::Empty => {}
TileKind::Rock => {
ground_shadow(commands, art, pos, TILE * 0.98);
commands.spawn((
BoardStatic,
image_sprite(&art.rock, Color::WHITE, Vec2::splat(TILE * 0.94)),
Transform::from_translation(pos.extend(z::TILE_FEATURE)),
));
}
TileKind::Castle(_) => {}
TileKind::Spawner(spawner) => {
commands.spawn((
BoardStatic,
SpawnerSprite {
period: spawner.period,
},
image_sprite(&art.hole, Color::WHITE, Vec2::splat(TILE * 0.78)),
Transform::from_translation(pos.extend(z::TILE_FEATURE)),
));
}
TileKind::Kelp => {
ground_shadow(
commands,
art,
pos + Vec2::new(0.0, -TILE * 0.22),
TILE * 0.7,
);
commands.spawn((
BoardStatic,
image_sprite(&art.kelp, Color::WHITE, Vec2::splat(TILE * 0.94)),
Transform::from_translation(pos.extend(z::TILE_FEATURE)),
));
}
TileKind::Pool => {
commands.spawn((
BoardStatic,
image_sprite(&art.pool, Color::WHITE, Vec2::splat(TILE * 0.98)),
Transform::from_translation(pos.extend(z::POOL)),
));
}
TileKind::Turnstile { .. } => {
commands.spawn((
BoardStatic,
image_sprite(&art.shadow, Color::WHITE, Vec2::splat(TILE * 0.8)),
Transform::from_translation(pos.extend(z::POOL)),
));
}
}
}
pub fn spawn_static_board(commands: &mut Commands, board: &Board, art: &Art) {
spawn_dusk_shore(commands, board, art);
for (x, y, kind) in board.tiles() {
let pos = layout::tile_center(board, x, y);
spawn_sand(commands, art, pos, x, y);
spawn_tile_feature(commands, art, pos, kind);
}
spawn_walls(commands, board, art);
spawn_weather(commands, board, art);
}
const CLOUD_REACH: f32 = 2400.0;
#[derive(Component)]
pub struct CloudShadow {
speed: f32,
edge: f32,
}
fn spawn_weather(commands: &mut Commands, board: &Board, art: &Art) {
let w = f32::from(board.width()) * TILE;
let h = f32::from(board.height()) * TILE;
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(
time: Res<Time>,
settings: Res<crate::app::settings::GameSettings>,
mut clouds: Query<(&CloudShadow, &mut Transform)>,
) {
if settings.reduced_motion {
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;
}
}
}
fn spawn_dusk_shore(commands: &mut Commands, board: &Board, art: &Art) {
const REACH: f32 = super::wash::REACH;
let top = f32::from(board.height()) * TILE / 2.0 + 26.0;
let plane = |size: Vec2, at: Vec2, z: f32, color: Color| {
(
BoardStatic,
Sprite::from_color(color, size),
Transform::from_translation(at.extend(z)),
)
};
commands.spawn(plane(
Vec2::splat(REACH),
Vec2::ZERO,
z::SAND - 10.0,
Color::srgb(0.38, 0.34, 0.25),
));
let shore = top + TILE * 0.55;
commands.spawn(plane(
Vec2::new(REACH, REACH / 2.0),
Vec2::new(0.0, shore + REACH / 4.0),
z::SAND - 9.8,
Color::srgb(0.13, 0.24, 0.33),
));
commands.spawn(plane(
Vec2::new(REACH, 26.0),
Vec2::new(0.0, shore + 13.0),
z::SAND - 9.7,
Color::srgb(0.18, 0.32, 0.42),
));
commands.spawn(plane(
Vec2::new(REACH, 18.0),
Vec2::new(0.0, shore - 9.0),
z::SAND - 9.7,
Color::srgb(0.31, 0.29, 0.22),
));
commands.spawn((
BoardStatic,
Sprite {
image: art.foam.clone(),
color: Color::srgba(1.0, 1.0, 1.0, 0.28),
custom_size: Some(Vec2::new(REACH, 12.0)),
..default()
},
Transform::from_translation(Vec3::new(0.0, shore + 2.0, z::SAND - 9.6)),
));
}
fn spawn_walls(commands: &mut Commands, board: &Board, art: &Art) {
let plank_size = Vec2::new(TILE + 8.0, 13.0);
let plank = |commands: &mut Commands, center: Vec2, vertical: bool| {
let rotation = if vertical {
Quat::from_rotation_z(std::f32::consts::FRAC_PI_2)
} else {
Quat::IDENTITY
};
commands.spawn((
BoardStatic,
image_sprite(&art.plank, Color::srgba(0.10, 0.07, 0.05, 0.34), plank_size),
Transform::from_translation((center + layout::SUN).extend(z::TILE_FEATURE - 0.06))
.with_rotation(rotation),
));
commands.spawn((
BoardStatic,
image_sprite(&art.plank, Color::WHITE, plank_size),
Transform::from_translation(center.extend(z::WALL)).with_rotation(rotation),
));
};
let mut corner_hits = vec![0u8; (board.width() as usize + 1) * (board.height() as usize + 1)];
let corner_index = |cx: u16, cy: u16| cy as usize * (board.width() as usize + 1) + cx as usize;
for (x, y, _) in board.tiles() {
let pos = layout::tile_center(board, x, y);
if board.wall_at(x, y, Direction::Up) {
plank(commands, pos + Vec2::new(0.0, TILE / 2.0), false);
corner_hits[corner_index(u16::from(x), u16::from(y))] += 1;
corner_hits[corner_index(u16::from(x) + 1, u16::from(y))] += 1;
}
if board.wall_at(x, y, Direction::Left) {
plank(commands, pos + Vec2::new(-TILE / 2.0, 0.0), true);
corner_hits[corner_index(u16::from(x), u16::from(y))] += 1;
corner_hits[corner_index(u16::from(x), u16::from(y) + 1)] += 1;
}
if y == board.height() - 1 && board.wall_at(x, y, Direction::Down) {
plank(commands, pos + Vec2::new(0.0, -TILE / 2.0), false);
}
if x == board.width() - 1 && board.wall_at(x, y, Direction::Right) {
plank(commands, pos + Vec2::new(TILE / 2.0, 0.0), true);
}
}
let (w, h) = (
f32::from(board.width()) * TILE,
f32::from(board.height()) * TILE,
);
for cy in 1..u16::from(board.height()) {
for cx in 1..u16::from(board.width()) {
if corner_hits[corner_index(cx, cy)] == 0 {
continue;
}
let corner = Vec2::new(
f32::from(cx) * TILE - w / 2.0,
h / 2.0 - f32::from(cy) * TILE,
);
commands.spawn((
BoardStatic,
image_sprite(&art.post, Color::WHITE, Vec2::splat(15.0)),
Transform::from_translation(corner.extend(z::WALL + 0.1)),
));
}
}
for (size, offset, rot) in [
(
Vec2::new(w + 36.0, 18.0),
Vec2::new(0.0, h / 2.0 + 9.0),
std::f32::consts::PI,
),
(
Vec2::new(w + 36.0, 18.0),
Vec2::new(0.0, -h / 2.0 - 9.0),
0.0,
),
(
Vec2::new(h + 36.0, 18.0),
Vec2::new(-w / 2.0 - 9.0, 0.0),
-std::f32::consts::FRAC_PI_2,
),
(
Vec2::new(h + 36.0, 18.0),
Vec2::new(w / 2.0 + 9.0, 0.0),
std::f32::consts::FRAC_PI_2,
),
] {
commands.spawn((
BoardStatic,
image_sprite(&art.wet, Color::WHITE, size),
Transform::from_translation(offset.extend(z::WET))
.with_rotation(Quat::from_rotation_z(rot)),
));
}
}
pub fn sync_signposts(
mut commands: Commands,
sim: Res<Sim>,
art: Res<Art>,
mut covered: Local<Vec<bool>>,
existing: Query<(Entity, &SignpostSprite)>,
) {
let board = &sim.0;
covered.clear();
covered.resize(board.width() as usize * board.height() as usize, false);
for (entity, sprite) in &existing {
if !on_board(board, sprite.x, sprite.y) {
commands.entity(entity).despawn();
continue;
}
let idx = usize::from(board.index_of(sprite.x, sprite.y));
match board.signpost_at(sprite.x, sprite.y) {
Some(sp) if sp.dir == sprite.dir && sp.owner == sprite.owner => covered[idx] = true,
Some(_) | None => commands.entity(entity).despawn(),
}
}
for (x, y, _) in board.tiles() {
let idx = usize::from(board.index_of(x, y));
if covered[idx] {
continue;
}
let Some(sp) = board.signpost_at(x, y) else {
continue;
};
let pos = layout::tile_center(board, x, y);
commands
.spawn((
SignpostSprite {
x,
y,
dir: sp.dir,
owner: sp.owner,
age: 0.0,
planted: sp.placed,
},
image_sprite(
&art.arrow,
palette::player_color(sp.owner).lighter(0.12),
Vec2::splat(TILE * 0.88),
),
Transform::from_translation(pos.extend(z::SIGNPOST))
.with_rotation(layout::dir_rotation(sp.dir)),
))
.with_children(|parent| {
parent.spawn((
SignpostShadow,
image_sprite(
&art.arrow,
Color::srgba(0.14, 0.10, 0.06, 0.34),
Vec2::splat(TILE * 0.88),
),
Transform::from_translation(Vec3::new(0.0, 0.0, -0.05)),
));
});
}
}
#[allow(clippy::type_complexity)]
pub fn dress_signposts(
time: Res<Time>,
sim: Res<Sim>,
art: Res<Art>,
settings: Res<crate::app::settings::GameSettings>,
mut posts: Query<(&mut SignpostSprite, &mut Sprite, &mut Transform, &Children)>,
mut shadows: Query<
(&mut Sprite, &mut Transform),
(With<SignpostShadow>, Without<SignpostSprite>),
>,
) {
let board = &sim.0;
let dt = time.delta_secs();
for (mut post, mut sprite, mut transform, children) in &mut posts {
post.age += dt;
if !on_board(board, post.x, post.y) {
continue; }
let Some(sp) = board.signpost_at(post.x, post.y) else {
continue;
};
if sp.placed != post.planted {
post.planted = sp.placed;
post.age = 0.0;
}
let worn = sp.health == SignpostHealth::Worn;
let wanted = if worn { &art.arrow_worn } else { &art.arrow };
if sprite.image != *wanted {
sprite.image = wanted.clone();
}
let fade = board.signpost_fade(&sp);
let alpha = post_alpha(fade);
let mut color = palette::player_color(post.owner).lighter(0.12);
if worn {
color = color.darker(0.06);
}
sprite.color = color.with_alpha(alpha);
let pop = if settings.reduced_motion {
1.0
} else {
plant_pop(post.age)
};
transform.scale = Vec3::splat(pop * wither(fade));
for child in children {
let Ok((mut shadow, mut shadow_tf)) = shadows.get_mut(*child) else {
continue;
};
shadow.color = Color::srgba(0.14, 0.10, 0.06, 0.34 * alpha);
if shadow.image != *wanted {
shadow.image = wanted.clone();
}
let scale = transform.scale.x.max(f32::EPSILON);
let local = transform.rotation.inverse() * (layout::SUN / scale).extend(0.0);
shadow_tf.translation = Vec3::new(local.x, local.y, -0.05);
}
}
}
#[derive(Component)]
pub struct TurnstileSprite {
x: u8,
y: u8,
right: bool,
}
pub fn sync_turnstiles(
mut commands: Commands,
sim: Res<Sim>,
art: Res<Art>,
mut existing: Query<(Entity, &mut TurnstileSprite)>,
) {
let board = &sim.0;
let mut covered: Vec<(u8, u8)> = Vec::new();
for (entity, mut sprite) in &mut existing {
if !on_board(board, sprite.x, sprite.y) {
commands.entity(entity).despawn();
continue;
}
match board.tile_at(sprite.x, sprite.y) {
TileKind::Turnstile { next_right } => {
if next_right != sprite.right {
sprite.right = next_right;
}
covered.push((sprite.x, sprite.y));
}
TileKind::Empty
| TileKind::Rock
| TileKind::Castle(_)
| TileKind::Spawner(_)
| TileKind::Kelp
| TileKind::Pool => commands.entity(entity).despawn(),
}
}
for (x, y, kind) in board.tiles() {
let TileKind::Turnstile { next_right } = kind else {
continue;
};
if covered.contains(&(x, y)) {
continue;
}
let tilt = if next_right { -0.6 } else { 0.6 };
commands.spawn((
TurnstileSprite {
x,
y,
right: next_right,
},
image_sprite(&art.log, Color::WHITE, Vec2::new(TILE * 0.94, TILE * 0.3)),
Transform::from_translation(
layout::tile_center(board, x, y).extend(z::TILE_FEATURE + 0.1),
)
.with_rotation(Quat::from_rotation_z(tilt)),
));
}
}
#[derive(Component)]
pub struct SpawnerSprite {
period: u32,
}
pub fn pulse_spawners(sim: Res<Sim>, mut holes: Query<(&SpawnerSprite, &mut Transform)>) {
let tick = sim.0.ticks();
for (hole, mut transform) in &mut holes {
if hole.period == 0 {
continue;
}
let phase = (tick % u64::from(hole.period)) as f32 / hole.period as f32;
let swell = if phase > 0.75 {
1.0 + (phase - 0.75) * 0.6
} else {
1.0
};
transform.scale = Vec3::splat(swell);
}
}
pub fn animate_turnstiles(time: Res<Time>, mut logs: Query<(&TurnstileSprite, &mut Transform)>) {
for (sprite, mut transform) in &mut logs {
let target = if sprite.right { -0.6 } else { 0.6 };
let (_, _, current) = transform.rotation.to_euler(EulerRot::XYZ);
let next = current + (target - current) * (time.delta_secs() * 14.0).min(1.0);
if (next - current).abs() > 0.0005 {
transform.rotation = Quat::from_rotation_z(next);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_plant_hands_over_to_rest_without_a_step() {
assert!(plant_pop(0.0) > 1.3, "driven in big: {}", plant_pop(0.0));
let last = plant_pop(PLANT_POP - 1e-4);
assert!(
(last - 1.0).abs() < 0.01,
"the curve ends at {last}, and then snaps to 1.0"
);
assert_eq!(plant_pop(PLANT_POP), 1.0, "which is where rest begins");
assert_eq!(plant_pop(PLANT_POP * 4.0), 1.0, "and stays");
}
#[test]
fn the_plant_squashes_without_ever_turning_inside_out() {
let mut dipped = false;
for step in 0..=40 {
let pop = plant_pop(PLANT_POP * step as f32 / 40.0);
assert!(pop > 0.5, "collapsed to {pop} at step {step}");
assert!(pop < 1.4, "blew up to {pop} at step {step}");
dipped |= pop < 0.98;
}
assert!(dipped, "no squash at all: it only ever grows");
}
#[test]
fn a_post_never_fades_past_reading() {
for step in 0..=20 {
let alpha = post_alpha(step as f32 / 20.0);
assert!((0.55..=1.0).contains(&alpha), "{alpha} is off the scale");
}
assert_eq!(post_alpha(1.0), 1.0, "a fresh post is at full strength");
assert!(post_alpha(0.0) >= 0.55, "and a spent one is still legible");
}
#[test]
fn an_expiring_post_withers_but_does_not_shrivel() {
assert_eq!(wither(1.0), 1.0, "a fresh post is full size");
assert!(wither(0.0) >= 0.86, "and a spent one is only a little less");
let mut last = f32::MAX;
for step in 0..=20 {
let size = wither(1.0 - step as f32 / 20.0);
assert!(size <= last, "it grew back at step {step}");
last = size;
}
}
}