use super::{CreatureShadow, Tween, smoothing_alpha, tweened_pos, unturned};
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, 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,
}
#[derive(Component, Default)]
pub struct Crowding {
spread: Vec2,
shrink: f32,
}
const CROWD_RADIUS: f32 = TILE * 0.45;
const CROWD_SPREAD: f32 = TILE * 0.32;
const CROWD_SHRINK: f32 = 0.28;
fn crowd_level(neighbours: usize) -> f32 {
(neighbours as f32 / 3.0).min(1.0)
}
fn crowd_lane(id: u32) -> f32 {
let lane = (id.wrapping_mul(2_654_435_761) >> 16) % 5;
lane as f32 / 2.0 - 1.0
}
fn crowd_target(neighbours: usize, id: u32, heading: Vec2) -> (Vec2, f32) {
let level = crowd_level(neighbours);
let across = Vec2::new(-heading.y, heading.x);
(
across * crowd_lane(id) * CROWD_SPREAD * level,
CROWD_SHRINK * level,
)
}
fn count_neighbours(board: &crate::sim::Board, into: &mut HashMap<u32, usize>) {
into.clear();
let at: Vec<(u32, Vec2)> = board
.crabs()
.iter()
.map(|crab| (crab.id, layout::pose_pos(board, crab.pose())))
.collect();
let cell = |p: Vec2| {
(
(p.x / CROWD_RADIUS).floor() as i32,
(p.y / CROWD_RADIUS).floor() as i32,
)
};
let mut cells: HashMap<(i32, i32), Vec<Vec2>> = HashMap::default();
for &(_, p) in &at {
cells.entry(cell(p)).or_default().push(p);
}
for &(id, p) in &at {
let (cx, cy) = cell(p);
let mut near = 0;
for dx in -1..=1 {
for dy in -1..=1 {
if let Some(others) = cells.get(&(cx + dx, cy + dy)) {
near += others
.iter()
.filter(|o| o.distance_squared(p) < CROWD_RADIUS * CROWD_RADIUS)
.count();
}
}
}
into.insert(id, near.saturating_sub(1));
}
}
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 resting_shell(kind: CrabKind, shell: Color, lure_ready: bool) -> Color {
match kind {
CrabKind::Molting if !lure_ready => {
let c = shell.to_srgba();
let grey = 0.299 * c.red + 0.587 * c.green + 0.114 * c.blue;
shell
.mix(&Color::srgba(grey, grey, grey, c.alpha), 0.7)
.darker(0.08)
}
CrabKind::Molting
| CrabKind::Common
| CrabKind::Juvenile
| CrabKind::Giant
| CrabKind::Golden
| CrabKind::Sparkling => shell,
}
}
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
}
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 claw_color(handed: Handedness, coop: bool) -> Color {
match (coop, handed) {
(true, handed) => palette::seat_paint(crate::sim::seat_of_claw(handed)),
(false, Handedness::Left) => CLAW_LEFT,
(false, Handedness::Right) => CLAW_RIGHT,
}
}
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 sprite_side(kind: CrabKind) -> f32 {
body_size(kind).x * 1.45
}
pub(crate) fn mirrored(handed: Handedness) -> bool {
match handed {
Handedness::Left => true,
Handedness::Right => false,
}
}
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(crate) fn crab_body(art: &Art, kind: CrabKind, handed: Handedness, color: Color) -> Sprite {
Sprite {
image: art.crab.clone(),
color,
custom_size: Some(Vec2::splat(sprite_side(kind))),
flip_y: mirrored(handed),
..default()
}
}
const CLAW_LIFT: f32 = 0.01;
pub(crate) fn crab_claw(
art: &Art,
handed: Handedness,
coop: bool,
side: Vec2,
) -> (Sprite, Transform) {
(
Sprite {
image: art.claw.clone(),
color: claw_color(handed, coop),
custom_size: Some(side),
flip_y: mirrored(handed),
..default()
},
Transform::from_translation(Vec3::new(0.0, 0.0, CLAW_LIFT)),
)
}
pub(crate) const WALK_FPS: f32 = 12.0;
fn gait_phase(id: u32) -> f32 {
(id % 64) as f32 * 0.618_034 * 4.0
}
const BOB: f32 = 0.035;
pub(crate) fn walk_bob(step: f32, reduced_motion: bool) -> f32 {
if reduced_motion {
1.0
} else {
1.0 + (step * std::f32::consts::PI).sin().abs() * BOB
}
}
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 crab in live.values() {
spawn_crab(&mut commands, &art, board, crab);
}
}
pub(crate) fn coop_claws(board: &crate::sim::Board) -> bool {
board.rules().claw_hands.is_some()
}
pub(crate) fn spawn_crab(
commands: &mut Commands,
art: &Art,
board: &crate::sim::Board,
crab: &Crab,
) -> Entity {
let pos = layout::creature_pos(board, crab.tile, crab.dir, crab.progress);
let size = body_size(crab.kind);
let side = Vec2::splat(sprite_side(crab.kind));
commands
.spawn((
CrabSprite {
id: crab.id,
kind: crab.kind,
shade: shade_of(crab.id),
},
Crowding::default(),
crab_body(
art,
crab.kind,
crab.handed,
shell_color(crab.kind, shade_of(crab.id)),
),
Transform::from_translation(pos.extend(layout::z::CREATURE))
.with_rotation(layout::dir_rotation(crab.dir)),
))
.with_children(|parent| {
parent.spawn((
CreatureShadow,
layout::image_sprite(
&art.shadow,
Color::WHITE,
Vec2::new(size.x * 1.35, size.y * 1.15),
),
Transform::from_translation(Vec3::new(0.0, 0.0, -0.5)),
));
parent.spawn(crab_claw(art, crab.handed, coop_claws(board), side));
})
.id()
}
type CrabBody = (
&'static CrabSprite,
&'static mut Crowding,
&'static mut Transform,
&'static mut Sprite,
&'static Children,
);
#[derive(bevy::ecs::system::SystemParam)]
pub struct CrabSprites<'w, 's> {
bodies: Query<'w, 's, CrabBody, Without<CreatureShadow>>,
shadows: Query<'w, 's, &'static mut Transform, (With<CreatureShadow>, Without<CrabSprite>)>,
}
pub fn interpolate_crabs(
sim: Res<Sim>,
fx: effects::Fx,
tween: Tween,
mut glint_clock: Local<f32>,
mut by_id: Local<HashMap<u32, Crab>>,
mut crowds: Local<HashMap<u32, usize>>,
crabs: CrabSprites,
) {
let effects::Fx {
mut commands,
art,
mut rng,
settings,
} = fx;
let Tween {
time,
fixed_time,
mut watch,
round,
round_clock,
} = tween;
let CrabSprites {
bodies: mut sprites,
mut shadows,
} = crabs;
let board = &sim.0;
let alpha = smoothing_alpha(board.ticks(), &mut watch, time.delta_secs(), &fixed_time);
let stopped = !round.moving(board);
by_id.clear();
by_id.extend(board.crabs().iter().map(|c| (c.id, *c)));
let glint_now = round.run_clock(board, &mut glint_clock, time.delta_secs()) >= 0.22;
if glint_now {
*glint_clock = 0.0;
}
let lure_tint = board.lure().map(|(owner, _)| palette::player_color(owner));
let lure_ready = board.lure_ready();
count_neighbours(board, &mut crowds);
let ease = 1.0 - (-time.delta_secs() * 8.0).exp();
let pulse_at = round_clock.0;
for (sprite, mut crowding, mut transform, mut body, children) in &mut sprites {
let Some(crab) = by_id.get(&sprite.id) else {
continue; };
let base = resting_shell(crab.kind, shell_color(crab.kind, sprite.shade), lure_ready);
let shell = match lure_tint {
Some(tint) => base.mix(&tint, 0.55),
None => base,
};
layout::tint(&mut body, shell);
let prev = layout::pose_pos(board, crab.prev);
let curr = layout::pose_pos(board, crab.pose());
let pos = tweened_pos(prev, curr, alpha);
let (dx, dy) = crab.dir.offset();
let heading = Vec2::new(dx as f32, -(dy as f32));
let neighbours = crowds.get(&sprite.id).copied().unwrap_or(0);
let (spread, shrink) = crowd_target(neighbours, sprite.id, heading);
let (was_spread, was_shrink) = (crowding.spread, crowding.shrink);
crowding.spread = was_spread + (spread - was_spread) * ease;
crowding.shrink = was_shrink + (shrink - was_shrink) * ease;
transform.translation = (pos + crowding.spread).extend(layout::z::CREATURE);
let moving = prev != curr && !stopped;
let wobble = if moving && !settings.reduced_motion {
(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 step = time.elapsed_secs() * WALK_FPS + gait_phase(sprite.id);
let wanted = if moving {
&art.crab_walk[step as usize % art.crab_walk.len()]
} else {
&art.crab
};
if body.image != *wanted {
body.image = wanted.clone();
}
let bob = if moving {
walk_bob(step, settings.reduced_motion)
} else {
1.0
};
let pulse = if sprite.kind == CrabKind::Molting && lure_ready {
1.0 + (pulse_at * 5.0).sin() * 0.06
} else {
1.0
};
transform.scale = Vec3::splat(pulse * bob * (1.0 - crowding.shrink));
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);
shadow.scale = Vec3::splat(1.0 / bob);
}
}
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 {
effects::spark_trail(
&mut commands,
&mut rng,
&art,
curr - heading * TILE * 0.3,
-heading,
);
}
}
CrabKind::Common | CrabKind::Juvenile | CrabKind::Giant | CrabKind::Molting => {}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_left_handed_crab_is_the_other_one_mirrored() {
assert!(mirrored(Handedness::Left));
assert!(!mirrored(Handedness::Right));
assert_ne!(
claw_color(Handedness::Left, false),
claw_color(Handedness::Right, false)
);
}
#[test]
fn a_coop_claw_wears_its_steering_seats_colour() {
use crate::app::palette;
for handed in [Handedness::Left, Handedness::Right] {
let seat = crate::sim::seat_of_claw(handed);
assert_eq!(crate::sim::claw_of(seat), Some(handed));
assert_eq!(claw_color(handed, true), palette::seat_paint(seat));
}
assert_ne!(
claw_color(Handedness::Left, true),
claw_color(Handedness::Right, true)
);
assert_eq!(claw_color(Handedness::Left, false), CLAW_LEFT);
assert_eq!(claw_color(Handedness::Right, false), CLAW_RIGHT);
}
#[test]
fn a_crab_is_drawn_bigger_than_its_shell_and_in_its_size() {
for kind in CrabKind::ALL {
assert!(sprite_side(kind) > body_size(kind).x, "{kind:?}");
}
let side = sprite_side;
assert!(side(CrabKind::Giant) > side(CrabKind::Common));
assert!(side(CrabKind::Common) > side(CrabKind::Juvenile));
}
#[test]
fn a_crowd_fills_up_at_three_neighbours() {
assert_eq!(crowd_level(0), 0.0);
assert!((crowd_level(1) - 1.0 / 3.0).abs() < 1e-6);
assert_eq!(crowd_level(3), 1.0);
assert_eq!(crowd_level(20), 1.0);
}
#[test]
fn a_crowd_fans_out_across_its_heading_and_a_lone_crab_stays_put() {
let east = Vec2::X;
for id in 0..40 {
assert_eq!(
crowd_target(0, id, east),
(Vec2::ZERO, 0.0),
"crab {id} alone"
);
let (spread, shrink) = crowd_target(5, id, east);
assert_eq!(spread.x, 0.0, "crab {id} moved along its heading");
assert!(spread.y.abs() <= CROWD_SPREAD + 1e-4, "{spread}");
assert_eq!(shrink, CROWD_SHRINK, "a pile is a full crowd");
assert_eq!(
crowd_target(5, id, east),
(spread, shrink),
"same crab, same lane"
);
}
let (_, some) = crowd_target(1, 3, east);
assert!((some - CROWD_SHRINK / 3.0).abs() < 1e-6);
let lanes: std::collections::BTreeSet<i32> =
(0..40).map(|id| (crowd_lane(id) * 2.0) as i32).collect();
assert_eq!(lanes.len(), 5, "{lanes:?}");
}
#[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_resting_molt_is_drawn_dimmed() {
let shell = body_color(CrabKind::Molting);
let saturation = |c: Color| {
let c = c.to_srgba();
let hi = c.red.max(c.green).max(c.blue);
let lo = c.red.min(c.green).min(c.blue);
hi - lo
};
assert_eq!(resting_shell(CrabKind::Molting, shell, true), shell);
let resting = resting_shell(CrabKind::Molting, shell, false);
assert!(saturation(resting) < saturation(shell) * 0.5, "{resting:?}");
for kind in CrabKind::ALL {
if kind != CrabKind::Molting {
assert_eq!(
resting_shell(kind, body_color(kind), false),
body_color(kind)
);
}
}
}
}