use crate::app::art::Art;
use crate::app::layout::{self, TILE};
use crate::app::palette;
use crate::app::{Sim, effects};
use crate::sim::{Crab, CrabKind, Gull, GullState, Handedness};
use bevy::color::Mix;
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
#[derive(Component)]
pub struct CrabSprite {
pub id: u32,
pub kind: CrabKind,
pub(crate) shade: f32,
}
const SHADE_SPREAD: f32 = 0.05;
pub(crate) fn shell_color(kind: CrabKind, shade: f32) -> Color {
let base = body_color(kind);
match kind {
CrabKind::Molting | CrabKind::Golden | CrabKind::Sparkling => base,
CrabKind::Common | CrabKind::Juvenile | CrabKind::Giant => {
if shade >= 0.0 {
base.lighter(shade)
} else {
base.darker(-shade)
}
}
}
}
pub(crate) fn shade_of(id: u32) -> f32 {
let mixed = id.wrapping_mul(2_654_435_761) >> 16;
(mixed % 1000) as f32 / 1000.0 * SHADE_SPREAD * 2.0 - SHADE_SPREAD
}
#[derive(Component)]
pub struct GullSprite(pub u32);
#[derive(Component)]
pub struct CreatureShadow;
const CLAW_LEFT: Color = Color::srgb(0.18, 0.49, 0.82); const CLAW_RIGHT: Color = Color::srgb(0.97, 0.82, 0.17);
pub(crate) fn body_color(kind: CrabKind) -> Color {
match kind {
CrabKind::Common => Color::srgb(0.91, 0.50, 0.29),
CrabKind::Juvenile => Color::srgb(0.96, 0.76, 0.42),
CrabKind::Giant => Color::srgb(0.70, 0.25, 0.17),
CrabKind::Molting => Color::srgb(0.61, 0.48, 0.82),
CrabKind::Golden => Color::srgb(1.0, 0.88, 0.20),
CrabKind::Sparkling => Color::srgb(0.75, 0.95, 1.0),
}
}
pub(crate) fn body_size(kind: CrabKind) -> Vec2 {
match kind {
CrabKind::Giant => Vec2::new(TILE * 0.80, TILE * 0.62),
CrabKind::Juvenile => Vec2::new(TILE * 0.42, TILE * 0.34),
CrabKind::Common | CrabKind::Molting | CrabKind::Golden | CrabKind::Sparkling => {
Vec2::new(TILE * 0.56, TILE * 0.44)
}
}
}
pub fn sync_crab_sprites(
mut commands: Commands,
sim: Res<Sim>,
art: Res<Art>,
mut live: Local<HashMap<u32, Crab>>,
existing: Query<(Entity, &CrabSprite)>,
) {
let board = &sim.0;
live.clear();
live.extend(board.crabs().iter().map(|c| (c.id, *c)));
for (entity, sprite) in &existing {
if live.remove(&sprite.id).is_none() {
commands.entity(entity).despawn();
}
}
for (&id, crab) in live.iter() {
let pos = layout::creature_pos(board, crab.tile, crab.dir, crab.progress);
let size = body_size(crab.kind);
let claw = match crab.handed {
Handedness::Left => CLAW_LEFT,
Handedness::Right => CLAW_RIGHT,
};
let claw_y = match crab.handed {
Handedness::Left => size.y * 0.45,
Handedness::Right => -size.y * 0.45,
};
commands
.spawn((
CrabSprite {
id,
kind: crab.kind,
shade: shade_of(id),
},
Sprite {
image: art.crab.clone(),
color: shell_color(crab.kind, shade_of(id)),
custom_size: Some(size * 1.25),
..default()
},
Transform::from_translation(pos.extend(layout::z::CREATURE))
.with_rotation(layout::dir_rotation(crab.dir)),
))
.with_children(|parent| {
parent.spawn((
CreatureShadow,
Sprite {
image: art.shadow.clone(),
custom_size: Some(Vec2::new(size.x * 1.35, size.y * 1.15)),
..default()
},
Transform::from_translation(Vec3::new(0.0, 0.0, -0.5)),
));
parent.spawn((
Sprite {
image: art.claw.clone(),
color: claw,
custom_size: Some(Vec2::splat(size.y * 0.62)),
..default()
},
Transform::from_translation(Vec3::new(size.x * 0.5, claw_y, 0.1)),
));
});
}
}
fn smoothing_alpha(
ticks: u64,
watch: &mut (u64, f32),
delta_secs: f32,
fixed_time: &Time<Fixed>,
) -> f32 {
let (last_tick, frozen_for) = watch;
if ticks == *last_tick {
*frozen_for += delta_secs;
} else {
*last_tick = ticks;
*frozen_for = 0.0;
}
if *frozen_for > fixed_time.timestep().as_secs_f32() * 1.5 {
1.0
} else {
fixed_time.overstep_fraction()
}
}
#[allow(clippy::too_many_arguments)]
pub fn interpolate_crabs(
sim: Res<Sim>,
art: Res<Art>,
fixed_time: Res<Time<Fixed>>,
time: Res<Time>,
settings: Res<crate::app::settings::GameSettings>,
mut commands: Commands,
mut rng: ResMut<effects::VisualRng>,
mut glint_clock: Local<f32>,
mut watch: Local<(u64, f32)>,
mut by_id: Local<HashMap<u32, Crab>>,
mut sprites: Query<
(&CrabSprite, &mut Transform, &mut Sprite, &Children),
Without<CreatureShadow>,
>,
mut shadows: Query<&mut Transform, (With<CreatureShadow>, Without<CrabSprite>)>,
) {
let board = &sim.0;
let alpha = smoothing_alpha(board.ticks(), &mut watch, time.delta_secs(), &fixed_time);
by_id.clear();
by_id.extend(board.crabs().iter().map(|c| (c.id, *c)));
*glint_clock += time.delta_secs();
let glint_now = *glint_clock >= 0.22;
if glint_now {
*glint_clock = 0.0;
}
let lure_tint = board.lure().map(|(owner, _)| palette::player_color(owner));
for (sprite, mut transform, mut body, children) in &mut sprites {
let Some(crab) = by_id.get(&sprite.id) else {
continue; };
let base = shell_color(crab.kind, sprite.shade);
body.color = match lure_tint {
Some(tint) => base.mix(&tint, 0.55),
None => base,
};
let prev = layout::pose_pos(board, crab.prev);
let curr = layout::pose_pos(board, crab.pose());
let pos = if prev.distance(curr) > TILE * 2.0 {
curr
} else {
prev.lerp(curr, alpha)
};
transform.translation = pos.extend(layout::z::CREATURE);
let moving = prev != curr;
let wobble = if moving {
(time.elapsed_secs() * 11.0 + f32::from(sprite.id as u16 % 32)).sin() * 0.06
} else {
0.0
};
transform.rotation = layout::dir_rotation(crab.dir) * Quat::from_rotation_z(wobble);
let frame_b = moving
&& ((time.elapsed_secs() * 7.0 + f32::from(sprite.id as u16 % 8)) as u32)
.is_multiple_of(2);
let wanted = if frame_b { &art.crab_b } else { &art.crab };
if body.image != *wanted {
body.image = wanted.clone();
}
if sprite.kind == CrabKind::Molting {
let pulse = 1.0 + (time.elapsed_secs() * 5.0).sin() * 0.06;
transform.scale = Vec3::splat(pulse);
}
let body_scale = transform.scale.x.max(f32::EPSILON);
for child in children {
if let Ok(mut shadow) = shadows.get_mut(*child) {
shadow.translation = unturned(&transform, layout::SUN / body_scale, -0.5);
}
}
if glint_now {
match sprite.kind {
CrabKind::Sparkling => effects::glint(
&mut commands,
&mut rng,
&art,
curr,
Color::srgba(0.8, 0.95, 1.0, 0.95),
),
CrabKind::Golden => {
effects::glint(
&mut commands,
&mut rng,
&art,
curr,
Color::srgba(1.0, 0.9, 0.3, 0.95),
);
if moving && !settings.reduced_motion {
let (dx, dy) = crab.dir.offset();
let heading = Vec2::new(dx as f32, -(dy as f32));
effects::spark_trail(
&mut commands,
&mut rng,
&art,
curr - heading * TILE * 0.3,
-heading,
);
}
}
CrabKind::Common | CrabKind::Juvenile | CrabKind::Giant | CrabKind::Molting => {}
}
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn sync_gull_sprites(
mut commands: Commands,
sim: Res<Sim>,
art: Res<Art>,
settings: Res<crate::app::settings::GameSettings>,
mut rng: ResMut<effects::VisualRng>,
mut live: Local<HashMap<u32, Gull>>,
mut before: Local<(Option<u64>, Vec<u32>)>,
existing: Query<(Entity, &GullSprite)>,
) {
let board = &sim.0;
let (last_ticks, seen) = &mut *before;
let fresh = board.ticks() == 0 || last_ticks.is_none_or(|last| board.ticks() < last);
if fresh {
seen.clear();
seen.extend(board.gulls().iter().map(|g| g.id));
}
*last_ticks = Some(board.ticks());
live.clear();
live.extend(board.gulls().iter().map(|g| (g.id, *g)));
for (entity, sprite) in &existing {
if live.remove(&sprite.0).is_none() {
commands.entity(entity).despawn();
}
}
for (&id, gull) in live.iter() {
let pos = layout::creature_pos(board, gull.tile, gull.dir, gull.progress);
if !settings.reduced_motion && !seen.contains(&id) {
kick_up(&mut commands, &mut rng, &art, pos);
}
commands
.spawn((
GullSprite(id),
Sprite {
image: art.gull.clone(),
custom_size: Some(Vec2::splat(TILE * 0.78)),
..default()
},
Transform::from_translation(pos.extend(layout::z::CREATURE + 0.2))
.with_rotation(layout::dir_rotation(gull.dir)),
))
.with_children(|parent| {
parent.spawn((
CreatureShadow,
Sprite {
image: art.shadow.clone(),
custom_size: Some(Vec2::new(TILE * 0.72, TILE * 0.54)),
..default()
},
Transform::from_translation(Vec3::new(0.0, 0.0, -0.5)),
));
});
}
seen.clear();
seen.extend(board.gulls().iter().map(|gull| gull.id));
}
#[allow(clippy::too_many_arguments)]
pub fn interpolate_gulls(
sim: Res<Sim>,
art: Res<Art>,
fixed_time: Res<Time<Fixed>>,
time: Res<Time>,
settings: Res<crate::app::settings::GameSettings>,
mut commands: Commands,
mut rng: ResMut<effects::VisualRng>,
mut watch: Local<(u64, f32)>,
mut by_id: Local<HashMap<u32, Gull>>,
mut aloft: Local<HashMap<u32, Flight>>,
mut sprites: Query<
(&GullSprite, &mut Transform, &mut Sprite, &Children),
Without<CreatureShadow>,
>,
mut shadows: Query<(&mut Transform, &mut Sprite), With<CreatureShadow>>,
) {
let board = &sim.0;
let fresh = board.ticks() < watch.0 || board.ticks() == 0;
let alpha = smoothing_alpha(board.ticks(), &mut watch, time.delta_secs(), &fixed_time);
by_id.clear();
by_id.extend(board.gulls().iter().map(|g| (g.id, *g)));
if fresh {
aloft.clear();
}
aloft.retain(|id, _| by_id.contains_key(id));
let t = time.elapsed_secs();
for (sprite, mut transform, mut image, children) in &mut sprites {
let Some(gull) = by_id.get(&sprite.0) else {
continue;
};
let prev = layout::pose_pos(board, gull.prev);
let curr = layout::pose_pos(board, gull.pose());
let pos = if prev.distance(curr) > TILE * 2.0 {
curr
} else {
prev.lerp(curr, alpha)
};
transform.translation = pos.extend(layout::z::CREATURE + 0.2);
let phase = f32::from(sprite.0 as u16 % 32);
let altitude = match gull.state {
GullState::Walking => {
aloft.remove(&sprite.0);
0.0
}
GullState::Flying { remaining } => {
let calm = settings.reduced_motion;
let flight = aloft.entry(gull.id).or_insert_with(|| {
if !calm {
kick_up(&mut commands, &mut rng, &art, pos);
}
Flight {
start: remaining,
altitude: 0.0,
descending: false,
}
});
let across = f32::from(gull.progress) / f32::from(crate::sim::SUBUNITS_PER_TILE);
flight.height(remaining, across)
}
};
let flying = altitude > 0.0 || matches!(gull.state, GullState::Flying { .. });
let wanted = if flying { &art.gull_fly } else { &art.gull };
if image.image != *wanted {
image.image = wanted.clone();
}
let (scale, waddle) = if flying {
(1.0 + 0.22 * altitude + (t * 16.0 + phase).sin() * 0.05, 0.0)
} else if prev == curr {
(1.0, 0.0)
} else {
(1.0, (t * 9.0 + phase).sin() * 0.05)
};
transform.rotation = layout::dir_rotation(gull.dir) * Quat::from_rotation_z(waddle);
transform.scale = Vec3::splat(scale);
for child in children {
let Ok((mut shadow_tf, mut shadow_sprite)) = shadows.get_mut(*child) else {
continue;
};
let drift = Vec2::new((t * 2.1 + phase).cos() * 2.5, (t * 1.7 + phase).sin() * 2.5);
let drop = 26.0 * altitude;
let world = layout::SUN + Vec2::new(-drop * 0.5, -drop) + drift * altitude;
shadow_tf.translation = unturned(&transform, world / scale, -0.5);
shadow_tf.scale = Vec3::splat((1.0 - 0.25 * altitude) / scale);
shadow_sprite.color = Color::srgba(1.0, 1.0, 1.0, 1.0 - 0.15 * altitude);
}
}
}
pub struct Flight {
start: u8,
altitude: f32,
descending: bool,
}
impl Flight {
fn height(&mut self, remaining: u8, across: f32) -> f32 {
if remaining <= 1 {
self.descending = true;
}
let climb = if remaining >= self.start { across } else { 1.0 };
let descend = if remaining <= 1 { 1.0 - across } else { 1.0 };
let wanted = climb.min(descend).clamp(0.0, 1.0);
self.altitude = if self.descending {
wanted.min(self.altitude)
} else {
wanted
};
self.altitude
}
}
fn unturned(parent: &Transform, world: Vec2, z: f32) -> Vec3 {
let local = parent.rotation.inverse() * world.extend(0.0);
Vec3::new(local.x, local.y, z)
}
fn kick_up(commands: &mut Commands, rng: &mut effects::VisualRng, art: &Art, pos: Vec2) {
effects::burst(
commands,
rng,
&effects::Burst {
image: art.puff.clone(),
pos,
color: Color::srgba(0.93, 0.87, 0.7, 0.75),
count: 5,
size: 13.0,
speed: 52.0,
gravity: 80.0,
},
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_crab_keeps_the_shell_it_was_born_with() {
for id in [0u32, 1, 7, 64, 1_000, 65_535, u32::MAX] {
assert_eq!(shade_of(id), shade_of(id), "id {id} drifted");
let shade = shade_of(id);
assert!(
(-SHADE_SPREAD..=SHADE_SPREAD).contains(&shade),
"id {id} strayed off the band: {shade}"
);
}
}
#[test]
fn a_crowd_is_not_twenty_copies_of_one_crab() {
let shades: Vec<f32> = (0..40).map(shade_of).collect();
let spread = shades.iter().fold(f32::MIN, |a, &b| a.max(b))
- shades.iter().fold(f32::MAX, |a, &b| a.min(b));
assert!(
spread > SHADE_SPREAD,
"forty crabs covered only {spread} of the band"
);
}
#[test]
fn a_shade_never_carries_a_shell_into_another_kind() {
const KINDS: [CrabKind; 6] = [
CrabKind::Common,
CrabKind::Juvenile,
CrabKind::Giant,
CrabKind::Molting,
CrabKind::Golden,
CrabKind::Sparkling,
];
let apart = |a: Color, b: Color| {
let (a, b) = (a.to_srgba(), b.to_srgba());
((a.red - b.red).powi(2) + (a.green - b.green).powi(2) + (a.blue - b.blue).powi(2))
.sqrt()
};
for kind in KINDS {
for id in 0..40u32 {
let shell = shell_color(kind, shade_of(id));
let own = apart(shell, body_color(kind));
for other in KINDS.into_iter().filter(|&k| k != kind) {
assert!(
own < apart(shell, body_color(other)),
"a shaded {kind:?} (id {id}) is nearer {other:?} than itself"
);
}
let srgba = shell.to_srgba();
for channel in [srgba.red, srgba.green, srgba.blue] {
assert!((0.0..=1.0).contains(&channel), "{kind:?} left the channel");
}
}
}
}
#[test]
fn a_shadow_holds_its_bearing_through_every_turn() {
for dir in [
crate::sim::Direction::Up,
crate::sim::Direction::Down,
crate::sim::Direction::Left,
crate::sim::Direction::Right,
] {
let parent = Transform::from_rotation(layout::dir_rotation(dir));
let local = unturned(&parent, layout::SUN, -0.5);
let world = parent.rotation * local;
assert!(
world.truncate().distance(layout::SUN) < 1e-4,
"{dir:?} bent the sun to {:?}",
world.truncate()
);
assert_eq!(local.z, -0.5, "and the layer is left alone");
}
}
#[test]
fn a_gull_climbs_once_and_only_ever_comes_down() {
let mut flight = Flight {
start: 3,
altitude: 0.0,
descending: false,
};
assert_eq!(flight.height(3, 0.0), 0.0, "leaves the sand at nothing");
assert!((flight.height(3, 0.5) - 0.5).abs() < 1e-6, "climbing");
assert_eq!(flight.height(2, 0.5), 1.0, "level across the middle");
assert!(
(flight.height(1, 0.25) - 0.75).abs() < 1e-6,
"and coming down over the last tile, which is `remaining == 1`"
);
let low = flight.height(1, 0.9);
assert!(low < 0.15, "all but on the sand: {low}");
assert!(flight.height(1, 0.0) <= low, "went back up to {low}");
}
fn fixed_clock(overstep: f32) -> Time<Fixed> {
let mut fixed = Time::<Fixed>::from_hz(30.0);
fixed.accumulate_overstep(fixed.timestep().mul_f32(overstep));
fixed
}
#[test]
fn the_sim_counts_as_frozen_only_past_one_and_a_half_periods() {
let fixed = fixed_clock(0.4);
let period = fixed.timestep().as_secs_f32();
let between = fixed.overstep_fraction();
assert!((0.0..1.0).contains(&between), "a fraction, got {between}");
let mut watch = (5u64, 0.0f32);
let mut alpha = 0.0;
for _ in 0..3 {
alpha = smoothing_alpha(5, &mut watch, period * 0.4, &fixed);
}
assert_eq!(
alpha, between,
"1.2 periods without a tick still interpolates"
);
let alpha = smoothing_alpha(5, &mut watch, period * 0.4, &fixed);
assert_eq!(alpha, 1.0, "1.6 periods without a tick holds still");
let alpha = smoothing_alpha(6, &mut watch, period * 0.4, &fixed);
assert_eq!(alpha, between, "a fresh tick resets the frozen count");
assert_eq!(watch, (6, 0.0), "the watch follows the new tick");
}
}