use crate::app::art::Art;
use crate::app::layout::{self, TILE};
use crate::app::materials::PRINT;
use crate::app::palette;
use crate::app::sim_events::{BoardMemory, CurrentBoard, SimEvent};
use crate::sim::TileKind;
use bevy::color::Mix;
use bevy::prelude::*;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Foot {
Left,
Right,
}
impl Foot {
fn other(self) -> Foot {
match self {
Foot::Left => Foot::Right,
Foot::Right => Foot::Left,
}
}
fn side(self) -> f32 {
match self {
Foot::Left => 5.0,
Foot::Right => -5.0,
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Footfall {
last: Vec2,
foot: Foot,
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct Fx<'w, 's> {
pub commands: Commands<'w, 's>,
pub art: Res<'w, Art>,
pub rng: ResMut<'w, VisualRng>,
pub settings: Res<'w, crate::app::settings::GameSettings>,
}
pub fn smoothstep(t: f32) -> f32 {
let t = t.clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
#[derive(Resource)]
pub struct VisualRng(u32);
impl Default for VisualRng {
fn default() -> Self {
VisualRng(0x9E37_79B9)
}
}
impl VisualRng {
pub(crate) fn from_clock() -> Self {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |since| since.subsec_nanos() ^ since.as_secs() as u32);
VisualRng(nanos | 1)
}
pub(crate) fn seeded(seed: u32) -> Self {
VisualRng(seed)
}
pub(crate) fn next(&mut self) -> u32 {
self.0 = self.0.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
self.0
}
pub(crate) fn range(&mut self, lo: f32, hi: f32) -> f32 {
lo + (self.next() >> 8) as f32 / 16_777_216.0 * (hi - lo)
}
}
#[derive(Resource, Default)]
pub struct Trauma(f32);
impl Trauma {
const MAX: f32 = 1.0;
const DECAY: f32 = 1.9;
const THROW: f32 = 7.0;
pub fn add(&mut self, amount: f32) {
self.0 = (self.0 + amount).min(Self::MAX);
}
pub fn offset(&mut self, dt: f32, elapsed: f32) -> Vec2 {
self.0 = (self.0 - dt * Self::DECAY).max(0.0);
if self.0 <= 0.0 {
return Vec2::ZERO;
}
let throw = Self::THROW * self.0 * self.0;
Vec2::new(
(elapsed * 37.0).sin() * (elapsed * 17.0).cos(),
(elapsed * 29.0).sin() * (elapsed * 23.0).cos(),
) * throw
}
}
#[derive(Component)]
pub struct Particle {
velocity: Vec2,
gravity: Vec2,
drag: f32,
spin: f32,
grow: f32,
size: f32,
ramp: Option<(Color, Color)>,
fade_in: f32,
peak: Option<f32>,
floor: Option<f32>,
hold: f32,
age: f32,
life: f32,
}
impl Default for Particle {
fn default() -> Self {
Particle {
velocity: Vec2::ZERO,
gravity: Vec2::ZERO,
drag: 0.0,
spin: 0.0,
grow: 0.0,
size: 1.0,
ramp: None,
fade_in: 0.09,
peak: None,
floor: None,
hold: 0.0,
age: 0.0,
life: 0.5,
}
}
}
impl Particle {
fn strength(&self) -> f32 {
let rise = self.fade_in * self.life;
let held = self.hold * self.life;
if held > 0.0 {
return match self.age < held {
true => 1.0,
false => 1.0 - (self.age - held) / (self.life - held).max(f32::EPSILON),
};
}
if self.age < rise {
self.age / rise.max(f32::EPSILON)
} else {
1.0 - (self.age - rise) / (self.life - rise).max(f32::EPSILON)
}
}
fn progress(&self) -> f32 {
(self.age / self.life.max(f32::EPSILON)).clamp(0.0, 1.0)
}
fn shade(&mut self, current: Color) -> Color {
let peak = *self.peak.get_or_insert(current.alpha());
let progress = self.progress();
let base = self
.ramp
.map_or(current, |(from, to)| from.mix(&to, progress));
base.with_alpha(peak * self.strength())
}
}
const SAND_DRY: Color = Color::srgba(0.9, 0.82, 0.62, 0.75);
const SAND_DAMP: Color = Color::srgba(0.62, 0.55, 0.40, 0.75);
const FEATHER_LIT: Color = Color::srgba(1.0, 1.0, 1.0, 0.95);
const FEATHER_GREY: Color = Color::srgba(0.72, 0.76, 0.82, 0.95);
pub struct Burst {
pub image: Handle<Image>,
pub pos: Vec2,
pub color: Color,
pub count: usize,
pub size: f32,
pub speed: f32,
pub gravity: f32,
}
pub fn burst(commands: &mut Commands, rng: &mut VisualRng, spec: &Burst) {
for _ in 0..spec.count {
let angle = rng.range(0.0, std::f32::consts::TAU);
let velocity = Vec2::from_angle(angle) * rng.range(spec.speed * 0.4, spec.speed);
commands.spawn((
Particle {
velocity,
gravity: Vec2::new(0.0, -spec.gravity),
drag: 1.1,
spin: rng.range(-4.0, 4.0),
grow: rng.range(0.2, 0.7),
life: rng.range(0.35, 0.6),
..default()
},
layout::image_sprite(
&spec.image,
spec.color,
Vec2::splat(spec.size * rng.range(0.7, 1.3)),
),
Transform::from_translation(spec.pos.extend(layout::z::PARTICLE)),
));
}
}
#[derive(Clone, Copy)]
pub(crate) struct Crumbs {
pub rim: Vec2,
pub foot: f32,
pub body: Color,
pub count: usize,
pub life: f32,
}
pub(crate) fn crumbs(commands: &mut Commands, rng: &mut VisualRng, art: &Art, spec: &Crumbs) {
let Crumbs {
rim,
foot,
body,
count,
life,
} = *spec;
let body = body.to_srgba();
for _ in 0..count {
let side = if rng.range(0.0, 1.0) < 0.5 { -1.0 } else { 1.0 };
let from = rim + Vec2::new(side * rng.range(4.3, 13.9), -rng.range(0.0, 5.3));
let dark = rng.range(0.70, 0.86);
commands.spawn((
Particle {
velocity: Vec2::new(side * rng.range(10.7, 32.0), rng.range(10.7, 37.3)),
gravity: Vec2::new(0.0, -363.0),
spin: rng.range(-3.0, 3.0),
fade_in: 0.0,
floor: Some(foot + rng.range(-8.5, 3.2)),
hold: 0.55,
life,
..default()
},
layout::image_sprite(
&art.puff,
Color::srgb(body.red * dark, body.green * dark, body.blue * dark),
Vec2::splat(rng.range(4.5, 6.6)),
),
Transform::from_translation(from.extend(layout::z::TILE_FEATURE + 0.7)),
));
}
}
fn feathers(commands: &mut Commands, rng: &mut VisualRng, art: &Art, pos: Vec2) {
for _ in 0..5 {
let angle = rng.range(0.0, std::f32::consts::TAU);
commands.spawn((
Particle {
velocity: Vec2::from_angle(angle) * rng.range(18.0, 46.0),
gravity: Vec2::new(0.0, -90.0),
drag: 1.6,
spin: rng.range(-5.0, 5.0),
ramp: Some((FEATHER_LIT, FEATHER_GREY)),
life: rng.range(0.45, 0.8),
..default()
},
layout::image_sprite(
&art.feather,
FEATHER_LIT,
Vec2::splat(rng.range(11.0, 18.0)),
),
Transform::from_translation(pos.extend(layout::z::PARTICLE)),
));
}
}
fn shooed(commands: &mut Commands, art: &Art, pos: Vec2, castle: Vec2, calm: bool) {
let away = (pos - castle).try_normalize().unwrap_or(Vec2::Y);
let heading = (away + Vec2::Y * 0.6).normalize();
if !calm {
ring(
commands,
art,
castle,
Color::srgba(0.95, 0.97, 1.0, 0.55),
TILE * 0.55,
2.0,
0.3,
);
}
flying_away(commands, art, pos, heading, calm);
}
fn flying_away(commands: &mut Commands, art: &Art, pos: Vec2, heading: Vec2, calm: bool) {
commands.spawn((
Particle {
velocity: if calm { Vec2::ZERO } else { heading * 150.0 },
grow: if calm { 0.0 } else { 0.6 },
fade_in: 0.0,
life: 0.75,
..default()
},
layout::image_sprite(&art.gull_fly, Color::WHITE, Vec2::splat(TILE * 0.78)),
Transform::from_translation(pos.extend(layout::z::PARTICLE))
.with_rotation(Quat::from_rotation_z(heading.to_angle())),
));
}
pub fn ring(
commands: &mut Commands,
art: &Art,
pos: Vec2,
color: Color,
size: f32,
grow: f32,
life: f32,
) {
commands.spawn((
Particle {
grow,
life,
fade_in: 0.0,
..default()
},
Sprite {
image: art.ring.clone(),
color,
custom_size: Some(Vec2::splat(size)),
..default()
},
Transform::from_translation(pos.extend(layout::z::PARTICLE)),
));
}
pub fn glint(commands: &mut Commands, rng: &mut VisualRng, art: &Art, pos: Vec2, color: Color) {
let offset = Vec2::new(rng.range(-14.0, 14.0), rng.range(-14.0, 14.0));
commands.spawn((
Particle {
velocity: Vec2::new(0.0, rng.range(4.0, 10.0)),
spin: rng.range(1.0, 3.0),
grow: -0.8,
life: rng.range(0.4, 0.7),
..default()
},
Sprite {
image: art.star.clone(),
color,
custom_size: Some(Vec2::splat(rng.range(8.0, 15.0))),
..default()
},
Transform::from_translation((pos + offset).extend(layout::z::PARTICLE)),
));
}
pub fn spark_trail(commands: &mut Commands, rng: &mut VisualRng, art: &Art, pos: Vec2, back: Vec2) {
for _ in 0..2 {
let spread = back.perp() * rng.range(-7.0, 7.0);
commands.spawn((
Particle {
velocity: back * rng.range(10.0, 26.0),
drag: 2.4,
spin: rng.range(-5.0, 5.0),
grow: -0.9,
life: rng.range(0.35, 0.6),
..default()
},
layout::image_sprite(
&art.star,
Color::srgba(1.0, 0.92, 0.45, 0.9),
Vec2::splat(rng.range(6.0, 11.0)),
),
Transform::from_translation((pos + spread).extend(layout::z::PARTICLE)),
));
}
}
pub fn score_pip(commands: &mut Commands, text: String, pos: Vec2, color: Color) {
let oversample = layout::MAX_ZOOM;
commands.spawn((
Particle {
velocity: Vec2::new(0.0, 46.0),
gravity: Vec2::new(0.0, -34.0),
life: 1.2,
fade_in: 0.0,
size: 1.0 / oversample,
..default()
},
Text2d::new(text),
crate::app::menu_ui::display_font(22.0 * oversample),
TextColor(color),
Transform::from_translation(pos.extend(layout::z::PIP))
.with_scale(Vec3::splat(1.0 / oversample)),
));
}
pub fn confetti(commands: &mut Commands, rng: &mut VisualRng, art: &Art, color: Color) {
for _ in 0..46 {
let x = rng.range(-6.5 * TILE, 6.5 * TILE);
let y = rng.range(3.0 * TILE, 6.5 * TILE);
commands.spawn((
Particle {
velocity: Vec2::new(rng.range(-18.0, 18.0), rng.range(-90.0, -40.0)),
gravity: Vec2::new(0.0, -55.0),
drag: 0.55,
spin: rng.range(-6.0, 6.0),
life: rng.range(1.6, 3.0),
..default()
},
layout::image_sprite(
&art.star,
color.lighter(rng.range(0.0, 0.2)),
Vec2::splat(rng.range(8.0, 16.0)),
),
Transform::from_translation(Vec3::new(x, y, layout::z::CONFETTI)),
));
}
}
#[derive(Component)]
pub struct Hop {
from: Vec2,
to: Vec2,
age: f32,
shell: Color,
path: HopPath,
cheer: bool,
handed: crate::sim::Handedness,
kicks: bool,
}
#[derive(Component)]
pub struct HopClaw {
big: bool,
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub enum Homeward {
Cheer,
Hop,
Behind(f32),
Swept(usize),
}
const CHEER: f32 = 0.45;
const CHEER_PUMPS: f32 = 2.0;
const CHEER_SWING: f32 = 24.0 * std::f32::consts::PI / 180.0;
const CHEER_CLAW_GROW: f32 = 0.28;
const CHEER_PUSH: f32 = 3.2;
const CHEER_BRACE: f32 = 0.05;
const CLAW_ROOT: Vec2 = Vec2::new(0.22, 0.24);
fn pump(cheered: f32) -> f32 {
if !(0.0..CHEER).contains(&cheered) {
return 0.0;
}
let u = cheered / CHEER;
(u * CHEER_PUMPS * std::f32::consts::PI)
.sin()
.abs()
.powf(0.7)
}
fn claw_pose(side: f32, big: bool, mirror: bool, pump: f32) -> Transform {
let across = if big != mirror { -1.0 } else { 1.0 };
let root = Vec2::new(CLAW_ROOT.x, CLAW_ROOT.y * across) * side;
let swing = CHEER_SWING * pump * across;
let (grow, push) = if big {
(1.0 + CHEER_CLAW_GROW * pump, CHEER_PUSH * pump)
} else {
(0.62 * (1.0 + 0.25 * pump), CHEER_PUSH * 0.8 * pump)
};
let turn = Quat::from_rotation_z(swing);
let held = root + (turn * (-root * grow).extend(0.0)).truncate() + Vec2::new(push, 0.0);
Transform::from_translation(held.extend(CLAW_LIFT))
.with_rotation(turn)
.with_scale(Vec3::splat(grow))
}
const CLAW_LIFT: f32 = 0.01;
#[derive(Clone, Copy, PartialEq, Debug)]
enum HopPath {
OverWall,
Swept { waiting: Option<f32> },
}
impl HopPath {
fn span(self) -> f32 {
match self {
HopPath::OverWall => HOP,
HopPath::Swept { .. } => SWEEP_HOP,
}
}
}
const HOP: f32 = 0.19;
const HOP_LIFT: f32 = 12.0;
pub(crate) const SWEEP_WAIT_Z: f32 = layout::z::CREATURE + 0.3;
pub(crate) fn sweep_arc(from: Vec2, to: Vec2, progress: f32) -> (Vec3, f32) {
let lift = (from.distance(to) * 0.35).min(TILE * 1.2);
let (at, scale) = arc(from, to, lift, progress);
(at.extend(layout::z::WALL + 0.3), scale)
}
const SWEEP_HOP: f32 = 0.75;
const SWEEP_STAGGER: f32 = 0.08;
const SWEEP_MIN_HOLD: f32 = 0.2;
const SWEEP_MAX_HOLD: f32 = 8.0;
const SWEPT_FROM: f32 = TILE * 1.5;
const ON_THE_DOORSTEP: f32 = TILE * 0.25;
const QUEUE_GAP: f32 = 0.12;
fn homeward(
pos: Vec2,
keep: Vec2,
points: i32,
sweeps: &mut usize,
doorsteps: &mut Vec<(Vec2, f32)>,
) -> Homeward {
let out = pos.distance(keep);
if out > SWEPT_FROM {
*sweeps += 1;
return Homeward::Swept(*sweeps - 1);
}
if let Some((_, wait)) = doorsteps.iter_mut().find(|(at, _)| at.distance(keep) < 1.0) {
*wait += QUEUE_GAP;
return Homeward::Behind(*wait);
}
if points > 0 && out > ON_THE_DOORSTEP {
doorsteps.push((keep, CHEER));
return Homeward::Cheer;
}
Homeward::Hop
}
pub struct BankedCrab {
pub id: u32,
pub from: Vec2,
pub to: Vec2,
pub kind: crate::sim::CrabKind,
pub handed: crate::sim::Handedness,
pub coop: bool,
pub points: i32,
}
pub fn bank_hop(commands: &mut Commands, art: &Art, crab: &BankedCrab, way: Homeward) {
use crate::app::creatures::{
claw_color, crab_body, mirrored, shade_of, shell_color, sprite_side,
};
let BankedCrab {
id,
from,
to,
kind,
handed,
coop,
points,
} = *crab;
let shell = shell_color(kind, shade_of(id));
let (age, path, z) = match way {
Homeward::Cheer => (-CHEER, HopPath::OverWall, layout::z::CREATURE + 0.1),
Homeward::Hop => (0.0, HopPath::OverWall, layout::z::CREATURE + 0.1),
Homeward::Behind(wait) => (-wait, HopPath::OverWall, layout::z::CREATURE + 0.05),
Homeward::Swept(slot) => (
-SWEEP_STAGGER * slot as f32,
HopPath::Swept { waiting: Some(0.0) },
SWEEP_WAIT_Z,
),
};
let cheer = way == Homeward::Cheer;
let hop = Hop {
from,
to,
age,
shell,
path,
cheer,
handed,
kicks: points > 0,
};
let toward = to - from;
let facing = match toward.length_squared() > 1.0 {
true => Quat::from_rotation_z(toward.to_angle()),
false => Quat::IDENTITY,
};
let side = sprite_side(kind);
let mirror = mirrored(handed);
let claw = |big: bool, color: Color| {
(
HopClaw { big },
Sprite {
image: art.claw.clone(),
color,
custom_size: Some(Vec2::splat(side)),
flip_y: mirror == big,
..default()
},
claw_pose(side, big, mirror, 0.0),
)
};
let mut body = commands.spawn((
hop,
crab_body(art, kind, handed, shell),
Transform::from_translation(from.extend(z)).with_rotation(facing),
));
body.with_child(claw(true, claw_color(handed, coop)));
if cheer {
body.with_child(claw(false, shell.with_alpha(0.0)));
}
}
fn bank_fade(commands: &mut Commands, art: &Art, crab: &BankedCrab) {
use crate::app::creatures::{crab_body, crab_claw, shade_of, shell_color, sprite_side};
let BankedCrab {
id,
from,
to: _,
kind,
handed,
coop,
points: _,
} = *crab;
let shell = shell_color(kind, shade_of(id));
let z = layout::z::CREATURE + 0.1;
commands.spawn((
standing_fade(),
crab_body(art, kind, handed, shell),
Transform::from_translation(from.extend(z)),
));
let (claw, lift) = crab_claw(art, handed, coop, Vec2::splat(sprite_side(kind)));
commands.spawn((
standing_fade(),
claw,
Transform::from_translation(from.extend(z) + lift.translation),
));
}
fn standing_fade() -> Particle {
Particle {
fade_in: 0.0,
life: 0.5,
..default()
}
}
fn kept_when_calm(event: &SimEvent) -> bool {
matches!(
event,
SimEvent::CastleRaided { .. }
| SimEvent::GullShooed { .. }
| SimEvent::GullFlewOff { .. }
| SimEvent::CrabBanked { .. }
| SimEvent::SignpostEvicted { .. }
)
}
pub(crate) fn arc(from: Vec2, to: Vec2, lift: f32, progress: f32) -> (Vec2, f32) {
let eased = progress * progress * (3.0 - 2.0 * progress);
let lift = (progress * std::f32::consts::PI).sin() * lift;
(
from.lerp(to, eased) + Vec2::new(0.0, lift),
1.0 - 0.7 * eased,
)
}
#[allow(clippy::too_many_arguments)]
pub fn advance_hops(
time: Res<Time>,
mut commands: Commands,
art: Res<Art>,
mut rng: ResMut<VisualRng>,
announcer: Res<crate::app::announce::Announcer>,
round: crate::app::round_state::RoundState,
sim: Res<crate::app::Sim>,
mut hops: Query<(Entity, &mut Hop, &mut Transform, &mut Sprite, &Children)>,
mut claws: Query<(&HopClaw, &mut Transform, &mut Sprite), Without<Hop>>,
mut castles: Query<(
&crate::app::board_render::CastleSprite,
&mut crate::app::board_render::CastleKick,
)>,
) {
if round.held() {
return;
}
let dt = time.delta_secs();
for (entity, mut hop, mut transform, mut sprite, children) in &mut hops {
let hop = &mut *hop;
if let HopPath::Swept {
waiting: Some(waited),
} = &mut hop.path
{
let glow = 0.45 + 0.35 * (*waited * 9.0).sin();
layout::tint(&mut sprite, hop.shell.mix(&palette::GOLD, glow));
let held = *waited < SWEEP_MIN_HOLD || announcer.holds_the_board();
if held && *waited < SWEEP_MAX_HOLD {
*waited += dt;
continue;
}
hop.age += dt;
if hop.age < 0.0 {
continue;
}
hop.path = HopPath::Swept { waiting: None };
layout::tint(&mut sprite, hop.shell);
burst(
&mut commands,
&mut rng,
&Burst {
image: art.puff.clone(),
pos: hop.from,
color: SAND_KICK,
count: 4,
size: 12.0,
speed: 36.0,
gravity: 30.0,
},
);
} else {
hop.age += dt;
}
let progress = (hop.age / hop.path.span()).clamp(0.0, 1.0);
if progress >= 1.0 {
commands.entity(entity).despawn();
if hop.kicks {
crate::app::board_render::kick_keep(&sim.0, &mut castles, hop.to);
}
burst(
&mut commands,
&mut rng,
&Burst {
image: art.puff.clone(),
pos: hop.to,
color: Color::srgba(0.95, 0.88, 0.7, 1.0),
count: 4,
size: 14.0,
speed: 40.0,
gravity: 40.0,
},
);
continue;
}
match hop.path {
HopPath::Swept { .. } => {
let (at, scale) = sweep_arc(hop.from, hop.to, progress);
transform.translation = at;
transform.scale = Vec3::splat(scale);
}
HopPath::OverWall => {
let (at, scale) = arc(hop.from, hop.to, HOP_LIFT, progress);
transform.translation.x = at.x;
transform.translation.y = at.y;
transform.scale = Vec3::splat(scale);
}
}
if hop.cheer {
cheer(hop, &mut transform, children, &mut claws);
}
}
}
fn cheer(
hop: &Hop,
body: &mut Transform,
children: &Children,
claws: &mut Query<(&HopClaw, &mut Transform, &mut Sprite), Without<Hop>>,
) {
let pumping = pump(hop.age + CHEER);
if hop.age < 0.0 {
body.scale = Vec3::splat(1.0 + CHEER_BRACE * pumping);
}
let mirror = crate::app::creatures::mirrored(hop.handed);
for child in children {
let Ok((claw, mut place, mut sprite)) = claws.get_mut(*child) else {
continue;
};
let side = sprite.custom_size.map_or(TILE, |size| size.x);
let pose = claw_pose(side, claw.big, mirror, pumping);
place.translation = pose.translation;
place.rotation = pose.rotation;
place.scale = pose.scale;
if !claw.big {
let pumped = sprite.color.with_alpha(pumping);
layout::tint(&mut sprite, pumped);
}
}
}
pub(crate) const SAND_KICK: Color = Color::srgba(0.93, 0.86, 0.70, 0.95);
const SPLASHES_PER_FRAME: usize = 4;
fn splash(commands: &mut Commands, rng: &mut VisualRng, art: &Art, pos: Vec2, size: f32) {
ring(
commands,
art,
pos,
Color::srgba(0.86, 0.95, 1.0, 0.55),
TILE * 0.28 * size,
2.2,
0.45,
);
burst(
commands,
rng,
&Burst {
image: art.puff.clone(),
pos,
color: Color::srgba(0.78, 0.90, 0.98, 0.9),
count: (5.0 * size).round() as usize,
size: 6.0 * size,
speed: 55.0,
gravity: 120.0,
},
);
}
fn in_pool(board: &crate::sim::Board, index: u16) -> bool {
let (x, y) = board.coords_u8(index);
board.tile_at(x, y) == TileKind::Pool
}
fn wades_in(was: Option<bool>, now: bool) -> bool {
now && was == Some(false)
}
pub fn splash_ponds(
mut commands: Commands,
current: CurrentBoard,
art: Res<Art>,
settings: Res<crate::app::settings::GameSettings>,
mut rng: ResMut<VisualRng>,
mut wet: Local<BoardMemory<(bool, u32), bool>>,
mut seen: Local<bevy::platform::collections::HashSet<(bool, u32)>>,
) {
let board = ¤t.sim.0;
let wet = wet.of(¤t);
seen.clear();
let mut thrown = 0;
let creatures = board
.crabs()
.iter()
.map(|crab| ((false, crab.id), crab.tile, crab.dir, crab.progress, 1.0))
.chain(board.gulls().iter().filter_map(|gull| {
matches!(gull.state, crate::sim::GullState::Walking).then_some((
(true, gull.id),
gull.tile,
gull.dir,
gull.progress,
0.8,
))
}));
for (key, tile, dir, progress, size) in creatures {
seen.insert(key);
let now = in_pool(board, tile);
let was = wet.insert(key, now);
if wades_in(was, now) && !settings.reduced_motion && thrown < SPLASHES_PER_FRAME {
thrown += 1;
let pos = layout::creature_pos(board, tile, dir, progress);
splash(&mut commands, &mut rng, &art, pos, size);
}
}
wet.retain(|key, _| seen.contains(key));
}
const GOLDEN_RING_EVERY: f32 = 0.45;
pub fn golden_call_rings(
mut commands: Commands,
sim: Res<crate::app::Sim>,
art: Res<Art>,
time: Res<Time>,
settings: Res<crate::app::settings::GameSettings>,
round: crate::app::round_state::RoundState,
mut since: Local<f32>,
) {
let Some((_, x, y)) = sim.0.golden_call() else {
*since = GOLDEN_RING_EVERY;
return;
};
round.run_clock(&sim.0, &mut since, time.delta_secs());
if settings.reduced_motion || *since < GOLDEN_RING_EVERY {
return;
}
*since = 0.0;
let pos = layout::tile_center(&sim.0, x, y);
ring(
&mut commands,
&art,
pos,
palette::GOLD,
TILE * 0.5,
2.4,
0.5,
);
}
pub fn crab_trails(
mut commands: Commands,
current: CurrentBoard,
art: Res<Art>,
settings: Res<crate::app::settings::GameSettings>,
mut rng: ResMut<VisualRng>,
mut footfalls: Local<BoardMemory<u32, Footfall>>,
mut seen: Local<bevy::platform::collections::HashSet<u32>>,
) {
if settings.reduced_motion {
return;
}
const STRIDE: f32 = 20.0;
let board = ¤t.sim.0;
let footfalls = footfalls.of(¤t);
seen.clear();
for crab in board.crabs() {
seen.insert(crab.id);
let pos = layout::creature_pos(board, crab.tile, crab.dir, crab.progress);
let fall = footfalls.entry(crab.id).or_insert(Footfall {
last: pos,
foot: Foot::Right,
});
let step = pos - fall.last;
if step.length() < STRIDE {
continue;
}
let side = step.normalize_or_zero().perp() * fall.foot.side();
fall.last = pos;
fall.foot = fall.foot.other();
let (x, y) = board.coords_u8(crab.tile);
if board.tile_at(x, y) != TileKind::Empty {
continue; }
let life = rng.range(1.4, 2.2);
let size = rng.range(3.0, 4.2);
commands.spawn((
Particle {
life,
fade_in: 0.05,
size,
peak: Some(PRINT.alpha()),
..default()
},
art.shaded.quad(
&art.shaded.prints,
Transform::from_translation((pos + side).extend(layout::z::WET + 0.05)),
Vec2::splat(size),
),
crate::app::materials::fade_tag(PRINT.alpha()),
));
if rng.range(0.0, 1.0) < 0.3 {
commands.spawn((
Particle {
velocity: -step.normalize_or_zero() * rng.range(12.0, 26.0),
gravity: Vec2::new(0.0, -60.0),
spin: rng.range(-3.0, 3.0),
grow: 0.5,
ramp: Some((SAND_DRY, SAND_DAMP)),
life: rng.range(0.25, 0.4),
..default()
},
layout::image_sprite(&art.puff, SAND_DRY, Vec2::splat(rng.range(5.0, 8.0))),
Transform::from_translation(pos.extend(layout::z::PARTICLE)),
));
}
}
footfalls.retain(|id, _| seen.contains(id));
}
type ParticleParts = (
Entity,
&'static mut Particle,
&'static mut Transform,
Option<&'static mut Sprite>,
Option<&'static mut TextColor>,
Option<&'static mut bevy::mesh::MeshTag>,
);
pub fn update_particles(
time: Res<Time>,
round: crate::app::round_state::RoundState,
mut commands: Commands,
mut particles: Query<ParticleParts>,
) {
if round.held() {
return;
}
let dt = time.delta_secs();
for (entity, mut particle, mut transform, sprite, text_color, tag) in &mut particles {
particle.age += dt;
if particle.age >= particle.life {
commands.entity(entity).despawn();
continue;
}
let (gravity, drag) = (particle.gravity, particle.drag);
particle.velocity += gravity * dt;
if drag > 0.0 {
particle.velocity *= (1.0 - drag * dt).clamp(0.0, 1.0);
}
transform.translation.x += particle.velocity.x * dt;
transform.translation.y += particle.velocity.y * dt;
transform.rotation *= Quat::from_rotation_z(particle.spin * dt);
if let Some(floor) = particle.floor
&& particle.velocity.y < 0.0
&& transform.translation.y <= floor
{
transform.translation.y = floor;
particle.velocity = Vec2::ZERO;
particle.gravity = Vec2::ZERO;
particle.spin = 0.0;
}
let scale = (1.0 + particle.grow * particle.age).max(0.05);
transform.scale = Vec3::splat(particle.size * scale);
if let Some(mut sprite) = sprite {
let shaded = particle.shade(sprite.color);
layout::tint(&mut sprite, shaded);
}
if let Some(mut color) = text_color {
color.0 = particle.shade(color.0);
}
if let Some(mut tag) = tag {
let alpha = particle.shade(Color::WHITE).alpha();
tag.set_if_neq(crate::app::materials::fade_tag(alpha));
}
}
}
pub fn moment_effects(
fx: Fx,
mut events: MessageReader<SimEvent>,
cursors: Query<&crate::app::cursor::Cursor>,
hops: Query<&Hop>,
mut trauma: ResMut<Trauma>,
sim: Res<crate::app::Sim>,
) {
let Fx {
mut commands,
art,
mut rng,
settings,
} = fx;
let calm = settings.reduced_motion;
let mut sweeps = 0usize;
let mut doorsteps: Vec<(Vec2, f32)> = Vec::new();
for hop in &hops {
if hop.path != HopPath::OverWall || hop.age >= 0.0 {
continue;
}
match doorsteps
.iter_mut()
.find(|(at, _)| at.distance(hop.to) < 1.0)
{
Some((_, wait)) => *wait = wait.max(-hop.age),
None => doorsteps.push((hop.to, -hop.age)),
}
}
for event in events.read() {
if calm && !kept_when_calm(event) {
continue;
}
match event {
SimEvent::CrabBanked {
id,
pos,
keep,
kind,
handed,
points,
owner: _,
} => {
let way = homeward(*pos, *keep, *points, &mut sweeps, &mut doorsteps);
let crab = BankedCrab {
id: *id,
from: *pos,
to: *keep,
kind: *kind,
handed: *handed,
coop: crate::app::creatures::coop_claws(&sim.0),
points: *points,
};
if calm {
bank_fade(&mut commands, &art, &crab);
} else {
bank_hop(&mut commands, &art, &crab, way);
}
}
SimEvent::CrabEaten { pos } => {
burst(
&mut commands,
&mut rng,
&Burst {
image: art.puff.clone(),
pos: *pos,
color: Color::srgba(0.85, 0.92, 0.98, 1.0),
count: 5,
size: 12.0,
speed: 55.0,
gravity: 0.0,
},
);
feathers(&mut commands, &mut rng, &art, *pos);
trauma.add(0.10);
}
SimEvent::SignpostEvicted { owner, pos, dir }
if crate::app::cursor::post_noticed_here(&cursors, *owner) =>
{
commands.spawn((
Particle {
grow: if calm { 0.0 } else { 1.1 },
life: 0.5,
fade_in: 0.0,
..default()
},
layout::image_sprite(
&art.sign_paint,
palette::seat_paint(*owner),
Vec2::splat(layout::SIGN_SIZE),
),
Transform::from_translation(pos.extend(layout::z::PARTICLE))
.with_rotation(layout::dir_rotation(*dir)),
));
}
SimEvent::CrabSpawned { pos } => burst(
&mut commands,
&mut rng,
&Burst {
image: art.puff.clone(),
pos: *pos,
color: Color::srgba(0.9, 0.8, 0.6, 1.0),
count: 3,
size: 12.0,
speed: 35.0,
gravity: 50.0,
},
),
SimEvent::CastleRaided { pos, lost, .. } => {
if calm {
score_pip(
&mut commands,
format!("-{lost}"),
*pos + Vec2::new(0.0, 10.0),
Color::srgb(0.96, 0.3, 0.25),
);
continue;
}
commands.spawn((
Particle {
grow: 1.6,
life: 0.3,
fade_in: 0.0,
..default()
},
layout::image_sprite(
&art.puff,
Color::srgba(1.0, 1.0, 1.0, 1.0),
Vec2::splat(TILE * 1.1),
),
Transform::from_translation(pos.extend(layout::z::FLASH)),
));
burst(
&mut commands,
&mut rng,
&Burst {
image: art.puff.clone(),
pos: *pos,
color: Color::srgba(0.9, 0.8, 0.6, 1.0),
count: 8,
size: 16.0,
speed: 70.0,
gravity: 60.0,
},
);
ring(
&mut commands,
&art,
*pos,
Color::srgba(1.0, 0.5, 0.4, 0.85),
TILE * 0.7,
3.4,
0.45,
);
score_pip(
&mut commands,
format!("-{lost}"),
*pos + Vec2::new(0.0, 10.0),
Color::srgb(0.96, 0.3, 0.25),
);
trauma.add(0.55);
}
SimEvent::GullLanded { pos } => {
burst(
&mut commands,
&mut rng,
&Burst {
image: art.puff.clone(),
pos: *pos,
color: Color::srgba(0.92, 0.85, 0.68, 1.0),
count: 4,
size: 13.0,
speed: 45.0,
gravity: 70.0,
},
);
ring(
&mut commands,
&art,
*pos,
Color::srgba(0.95, 0.92, 0.85, 0.5),
TILE * 0.5,
2.2,
0.35,
);
trauma.add(0.14);
}
SimEvent::GullShooed { pos, castle } => {
shooed(&mut commands, &art, *pos, *castle, calm);
}
SimEvent::GullFlewOff { pos } => {
flying_away(&mut commands, &art, *pos, Vec2::Y, calm);
}
SimEvent::SignpostPlaced { owner, pos }
if crate::app::cursor::post_noticed_here(&cursors, *owner) =>
{
ring(
&mut commands,
&art,
*pos,
Color::srgba(1.0, 0.98, 0.9, 0.5),
TILE * 0.45,
1.9,
0.3,
);
burst(
&mut commands,
&mut rng,
&Burst {
image: art.puff.clone(),
pos: *pos,
color: SAND_KICK,
count: 6,
size: 7.0,
speed: 48.0,
gravity: 95.0,
},
);
}
SimEvent::SignpostRemoved { owner, pos }
if crate::app::cursor::post_noticed_here(&cursors, *owner) =>
{
burst(
&mut commands,
&mut rng,
&Burst {
image: art.puff.clone(),
pos: *pos,
color: Color::srgba(0.92, 0.86, 0.72, 1.0),
count: 3,
size: 11.0,
speed: 30.0,
gravity: 50.0,
},
);
}
SimEvent::SignpostPlaced { .. }
| SimEvent::SignpostRemoved { .. }
| SimEvent::SignpostEvicted { .. } => {}
SimEvent::SurgeStarted => trauma.add(0.7),
SimEvent::GoldenOut { pos } => {
burst(
&mut commands,
&mut rng,
&Burst {
image: art.star.clone(),
pos: *pos,
color: palette::GOLD,
count: 14,
size: 14.0,
speed: 120.0,
gravity: 90.0,
},
);
ring(
&mut commands,
&art,
*pos,
palette::GOLD,
TILE * 0.7,
4.5,
0.8,
);
ring(
&mut commands,
&art,
*pos,
Color::srgba(1.0, 1.0, 0.85, 0.9),
TILE * 0.4,
3.0,
0.5,
);
trauma.add(0.25);
}
SimEvent::GoldenCalled { pos } => {
ring(
&mut commands,
&art,
*pos,
palette::GOLD,
TILE * 0.6,
3.0,
0.6,
);
}
SimEvent::GullIncoming { .. }
| SimEvent::GullArrived
| SimEvent::GullTookOff
| SimEvent::TierUp { .. }
| SimEvent::TideEventFired { .. }
| SimEvent::LureStarted { .. }
| SimEvent::WheelLocked { .. }
| SimEvent::LureRefused { .. }
| SimEvent::RoundEnded => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reduced_motion_keeps_a_bank_and_a_lost_post_without_moving_them() {
let banked = SimEvent::CrabBanked {
id: 0,
owner: 0,
pos: Vec2::ZERO,
keep: Vec2::X,
points: 1,
kind: crate::sim::CrabKind::Common,
handed: crate::sim::Handedness::Right,
};
let evicted = SimEvent::SignpostEvicted {
owner: 0,
pos: Vec2::ZERO,
dir: crate::sim::Direction::Up,
};
assert!(kept_when_calm(&banked), "a bank still shows");
assert!(kept_when_calm(&evicted), "and so does a lost post");
assert!(
!kept_when_calm(&SimEvent::CrabEaten { pos: Vec2::ZERO }),
"the feathers of an eaten crab are fireworks"
);
let calm = standing_fade();
assert_eq!(calm.velocity, Vec2::ZERO, "it does not drift");
assert_eq!(calm.gravity, Vec2::ZERO, "or fall");
assert_eq!(calm.grow, 0.0, "or swell");
assert_eq!(calm.spin, 0.0, "or turn");
assert!(calm.life > 0.0, "it is there for a moment, then gone");
}
fn stage(seats: u8, calm: bool) -> App {
let mut app = App::new();
app.insert_resource(Art::blank());
app.init_resource::<VisualRng>();
app.init_resource::<Trauma>();
app.insert_resource(crate::app::settings::GameSettings {
reduced_motion: calm,
..default()
});
app.init_resource::<crate::app::sim_events::BoardGeneration>();
app.add_message::<SimEvent>();
app.insert_resource(State::new(crate::app::Screen::Versus));
app.init_resource::<crate::app::Paused>();
app.init_resource::<crate::app::pause::PauseMenu>();
app.init_resource::<crate::app::net::Online>();
app.insert_resource(crate::app::Sim(crate::sim::Board::new(4, 4, 0)));
for seat in 0..seats {
app.world_mut()
.spawn(crate::app::cursor::Cursor::seated(seat));
}
app
}
fn drawn(app: &mut App) -> usize {
let world = app.world_mut();
let live: Vec<Entity> = world
.query_filtered::<Entity, With<Particle>>()
.iter(world)
.collect();
for &entity in &live {
world.entity_mut(entity).despawn();
}
live.len()
}
#[test]
fn a_lost_post_is_drawn_where_it_is_heard() {
let traded = |owner| SimEvent::SignpostEvicted {
owner,
pos: Vec2::ZERO,
dir: crate::sim::Direction::Up,
};
for calm in [false, true] {
let mut app = stage(1, calm);
app.add_systems(Update, moment_effects);
app.world_mut().write_message(traded(0));
app.update();
assert_eq!(drawn(&mut app), 1, "your own lost arrow (calm: {calm})");
app.world_mut().write_message(traded(4));
app.update();
assert_eq!(drawn(&mut app), 0, "not a bot's (calm: {calm})");
}
let mut app = stage(0, false);
app.add_systems(Update, moment_effects);
app.world_mut().write_message(traded(4));
app.update();
assert_eq!(drawn(&mut app), 1, "a watcher sees every seat's");
}
#[test]
fn a_grain_comes_to_rest_on_its_floor_and_lies_there() {
let mut app = stage(0, false);
app.init_resource::<Time>();
app.add_systems(Update, update_particles);
let grain = app
.world_mut()
.spawn((
Particle {
velocity: Vec2::new(10.0, 20.0),
gravity: Vec2::new(0.0, -400.0),
fade_in: 0.0,
floor: Some(-10.0),
hold: 0.5,
life: 2.0,
..default()
},
Sprite::default(),
Transform::default(),
))
.id();
let mut landed = None;
for _ in 0..60 {
app.world_mut()
.resource_mut::<Time>()
.advance_by(std::time::Duration::from_secs_f32(1.0 / 60.0));
app.update();
let at = app
.world()
.get::<Transform>(grain)
.expect("alive")
.translation;
assert!(at.y >= -10.0, "fell through the floor to {}", at.y);
if at.y == -10.0 {
match landed {
None => landed = Some(at.x),
Some(x) => assert_eq!(at.x, x, "it slid once it landed"),
}
}
}
assert!(landed.is_some(), "it never landed");
let held = app.world().get::<Particle>(grain).expect("alive");
assert_eq!(held.strength(), 1.0, "still lying at full strength");
let faded = Particle { age: 1.5, ..*held };
assert!((faded.strength() - 0.5).abs() < 1e-5, "halfway out");
}
#[test]
fn a_fading_quad_carries_the_alpha_a_sprite_would_wear() {
let mut app = stage(0, false);
app.init_resource::<Time>();
app.add_systems(Update, update_particles);
let like = || Particle {
life: 1.0,
fade_in: 0.05,
peak: Some(PRINT.alpha()),
..default()
};
let quad = app
.world_mut()
.spawn((
like(),
crate::app::materials::fade_tag(PRINT.alpha()),
Transform::default(),
))
.id();
let sprite = app
.world_mut()
.spawn((
like(),
Sprite::from_color(PRINT, Vec2::ONE),
Transform::default(),
))
.id();
for _ in 0..8 {
app.world_mut()
.resource_mut::<Time>()
.advance_by(std::time::Duration::from_secs_f32(0.1));
app.update();
let tag = app
.world()
.get::<bevy::mesh::MeshTag>(quad)
.expect("alive")
.value;
let worn = app.world().get::<Sprite>(sprite).expect("alive").color;
assert_eq!(f32::from_bits(tag), worn.alpha());
assert!(worn.alpha() < PRINT.alpha(), "fading");
}
}
#[test]
fn a_particle_holds_still_under_a_pause() {
let mut app = stage(0, false);
app.init_resource::<Time>();
app.add_systems(Update, update_particles);
let puff = app
.world_mut()
.spawn((
Particle {
velocity: Vec2::new(30.0, 40.0),
life: 2.0,
..default()
},
Sprite::default(),
Transform::default(),
))
.id();
let step = |app: &mut App| {
app.world_mut()
.resource_mut::<Time>()
.advance_by(std::time::Duration::from_secs_f32(0.1));
app.update();
let age = app.world().get::<Particle>(puff).expect("alive").age;
let at = app
.world()
.get::<Transform>(puff)
.expect("alive")
.translation;
(age, at)
};
step(&mut app);
app.world_mut().resource_mut::<crate::app::Paused>().0 = true;
let before = step(&mut app);
assert_eq!(step(&mut app), before, "it moved under the pause");
app.world_mut().resource_mut::<crate::app::Paused>().0 = false;
assert!(step(&mut app).0 > before.0, "and carries on after it");
}
fn banked_at(id: u32, keep: Vec2, points: i32) -> SimEvent {
SimEvent::CrabBanked {
id,
owner: 0,
pos: keep + Vec2::new(TILE * 0.5, 0.0),
keep,
points,
kind: crate::sim::CrabKind::Common,
handed: crate::sim::Handedness::Right,
}
}
fn cheers(app: &mut App) -> (usize, usize) {
let world = app.world_mut();
let hops: Vec<bool> = world.query::<&Hop>().iter(world).map(|h| h.cheer).collect();
let cheering = hops.iter().filter(|&&c| c).count();
(cheering, hops.len() - cheering)
}
#[test]
fn only_the_first_crab_at_a_door_cheers() {
let mut app = stage(1, false);
app.add_systems(Update, moment_effects);
let keep = Vec2::new(100.0, 0.0);
app.world_mut().write_message(banked_at(1, keep, 1));
app.world_mut().write_message(banked_at(2, keep, 1));
app.update();
assert_eq!(cheers(&mut app), (1, 1), "one cheers, one goes in");
app.world_mut().write_message(banked_at(3, keep, 1));
app.world_mut().write_message(banked_at(4, -keep, 1));
app.world_mut().write_message(banked_at(5, keep * 3.0, -2));
app.update();
assert_eq!(cheers(&mut app), (2, 3), "the other keep cheers its own");
}
fn landings(app: &mut App, keep: Vec2) -> Vec<f32> {
let world = app.world_mut();
let mut out: Vec<f32> = world
.query::<&Hop>()
.iter(world)
.filter(|hop| hop.to == keep)
.map(|hop| hop.path.span() - hop.age)
.collect();
out.sort_by(f32::total_cmp);
out
}
#[test]
fn a_crab_behind_a_cheering_one_lands_after_it() {
let mut app = stage(1, false);
app.add_systems(Update, moment_effects);
let keep = Vec2::new(100.0, 0.0);
app.world_mut().write_message(banked_at(1, keep, 1));
app.world_mut().write_message(banked_at(2, keep, 1));
app.update();
app.world_mut().write_message(banked_at(3, keep, 1));
app.update();
let lands = landings(&mut app, keep);
assert_eq!(lands.len(), 3, "{lands:?}");
assert!(
(lands[0] - (CHEER + HOP)).abs() < 1e-5,
"the cheering crab lands first: {lands:?}"
);
for pair in lands.windows(2) {
assert!(
pair[1] - pair[0] >= QUEUE_GAP - 1e-5,
"one at a time: {lands:?}"
);
}
}
#[test]
fn a_crab_flown_to_its_gate_does_not_cheer_on_the_roof() {
let mut app = stage(1, false);
app.add_systems(Update, moment_effects);
let keep = Vec2::new(100.0, 0.0);
app.world_mut().write_message(SimEvent::CrabBanked {
id: 1,
owner: 0,
pos: keep,
keep,
points: 1,
kind: crate::sim::CrabKind::Common,
handed: crate::sim::Handedness::Right,
});
app.update();
assert_eq!(cheers(&mut app), (0, 1), "straight in");
app.world_mut().write_message(banked_at(2, keep, 1));
app.update();
assert_eq!(cheers(&mut app), (1, 1), "and the next walking in cheers");
}
#[test]
fn a_cheer_pumps_twice_about_the_claws_root() {
assert_eq!(pump(0.0), 0.0);
assert!(pump(CHEER - 1e-4) < 0.05, "at rest as the hop begins");
assert_eq!(pump(CHEER + 0.1), 0.0);
assert!((pump(CHEER * 0.25) - 1.0).abs() < 1e-3, "the first pump");
assert!((pump(CHEER * 0.75) - 1.0).abs() < 1e-3, "the second");
assert!(pump(CHEER * 0.5) < 0.01, "and down between");
let side = 40.0;
for (big, mirror) in [(true, false), (true, true), (false, false), (false, true)] {
let rest = claw_pose(side, big, mirror, 0.0);
let up = claw_pose(side, big, mirror, 1.0);
let across = if big != mirror { -1.0 } else { 1.0 };
let root = Vec2::new(CLAW_ROOT.x, CLAW_ROOT.y * across) * side;
let push = if big { CHEER_PUSH } else { CHEER_PUSH * 0.8 };
let at = |pose: Transform, local: Vec2| {
(pose.translation + pose.rotation * (local * pose.scale.x).extend(0.0)).truncate()
};
let rest_root = at(rest, root);
let up_root = at(up, root) - Vec2::new(push, 0.0);
assert!(
rest_root.distance(up_root) < 0.5,
"big {big} mirror {mirror}: {rest_root} vs {up_root}"
);
assert_eq!(up.rotation.to_euler(EulerRot::XYZ).2.signum(), across);
}
}
fn drawn_on_the_next_board<M>(
system: impl IntoScheduleConfigs<bevy::ecs::system::ScheduleSystem, M>,
first: crate::sim::Board,
second: crate::sim::Board,
) -> usize {
let mut app = stage(1, false);
app.insert_resource(crate::app::Sim(first));
app.add_systems(
Update,
(crate::app::sim_events::track_board, system).chain(),
);
app.update();
drawn(&mut app);
app.insert_resource(crate::app::Sim(second));
app.update();
drawn(&mut app)
}
#[test]
fn a_crab_new_to_the_beach_has_not_waded_in() {
use crate::sim::{CrabKind, Direction, Handedness};
let mut dry = crate::sim::Board::new(6, 4, 7);
dry.spawn_crab(1, 1, Direction::Right, Handedness::Left, CrabKind::Common);
let mut wet = crate::sim::Board::new(6, 4, 8);
wet.set_tile(1, 1, TileKind::Pool);
wet.spawn_crab(1, 1, Direction::Right, Handedness::Left, CrabKind::Common);
assert_eq!(dry.crabs()[0].id, wet.crabs()[0].id, "the same id");
assert_eq!(drawn_on_the_next_board(splash_ponds, dry, wet), 0);
}
#[test]
fn a_crab_new_to_the_beach_has_not_walked_there() {
use crate::sim::{CrabKind, Direction, Handedness};
let mut west = crate::sim::Board::new(6, 4, 7);
west.spawn_crab(0, 1, Direction::Right, Handedness::Left, CrabKind::Common);
let mut east = crate::sim::Board::new(6, 4, 8);
east.spawn_crab(5, 2, Direction::Left, Handedness::Left, CrabKind::Common);
assert_eq!(west.crabs()[0].id, east.crabs()[0].id, "the same id");
assert_eq!(drawn_on_the_next_board(crab_trails, west, east), 0);
}
#[test]
fn in_pool_reads_the_creatures_own_tile() {
let mut board = crate::sim::Board::new(5, 4, 1);
board.set_tile(2, 1, TileKind::Pool);
let index = |x: u16, y: u16| y * 5 + x;
assert!(in_pool(&board, index(2, 1)));
assert!(!in_pool(&board, index(1, 1)), "the sand beside it");
assert!(!in_pool(&board, index(2, 2)), "the row below");
}
#[test]
fn a_splash_is_wading_in_and_only_that() {
assert!(wades_in(Some(false), true), "sand to water");
assert!(!wades_in(Some(true), true), "water to water, along a lane");
assert!(!wades_in(Some(true), false), "climbing out");
assert!(!wades_in(Some(false), false), "sand to sand");
assert!(!wades_in(None, true), "first seen, already in");
}
#[test]
fn a_visual_draw_never_leaves_the_half_open_range() {
let mut rng = VisualRng::default();
for (lo, hi) in [(0.35f32, 0.6f32), (-4.0, 4.0), (0.0, std::f32::consts::TAU)] {
let (mut min, mut max) = (f32::MAX, f32::MIN);
for _ in 0..10_000 {
let draw = rng.range(lo, hi);
assert!((lo..hi).contains(&draw), "{draw} is outside {lo}..{hi}");
min = min.min(draw);
max = max.max(draw);
}
assert!(
min < lo + (hi - lo) * 0.05,
"min {min} never came near {lo}"
);
assert!(
max > hi - (hi - lo) * 0.05,
"max {max} never came near {hi}"
);
}
}
#[test]
fn a_particle_fades_in_and_out_of_its_whole_life() {
let at = |age: f32, fade_in: f32| {
Particle {
age,
life: 1.0,
fade_in,
..Default::default()
}
.strength()
};
assert_eq!(at(0.0, 0.2), 0.0, "born at nothing");
assert!((at(0.2, 0.2) - 1.0).abs() < 1e-6, "full at the top");
assert!((at(0.6, 0.2) - 0.5).abs() < 1e-6, "half way down");
assert!(at(1.0, 0.2).abs() < 1e-6, "gone at the end");
assert!((at(0.0, 0.0) - 1.0).abs() < 1e-6);
assert!((at(0.25, 0.0) - 0.75).abs() < 1e-6);
}
#[test]
fn shading_a_particle_twice_lands_in_the_same_place() {
let mut particle = Particle {
life: 1.0,
fade_in: 0.0,
age: 0.25,
..Default::default()
};
let spawn = Color::srgba(1.0, 0.9, 0.7, 0.8);
let once = particle.shade(spawn);
assert!(
(once.alpha() - 0.6).abs() < 1e-6,
"a peak of 0.8 at three-quarter strength: {}",
once.alpha()
);
let twice = particle.shade(once);
assert!(
(twice.alpha() - once.alpha()).abs() < 1e-6,
"the peak is the spawn alpha for ever, not what was written last: {} then {}",
once.alpha(),
twice.alpha()
);
}
#[test]
fn floating_text_is_held_to_the_alpha_it_asked_for() {
let mut particle = Particle {
life: 1.0,
fade_in: 0.0,
..default()
};
let asked = Color::srgba(1.0, 1.0, 1.0, 0.6);
let opening = particle.shade(asked);
assert!(
(opening.alpha() - 0.6).abs() < 1e-5,
"arrived at {} rather than the 0.6 it asked for",
opening.alpha()
);
particle.age = 0.5;
let midway = particle.shade(opening);
assert!((midway.alpha() - 0.3).abs() < 1e-5, "{}", midway.alpha());
particle.age = 1.0;
assert!(particle.shade(midway).alpha() < 1e-5, "gone by the end");
}
#[test]
fn a_ramp_moves_the_colour_without_touching_the_fade() {
let mut particle = Particle {
life: 1.0,
fade_in: 0.0,
age: 1.0,
ramp: Some((FEATHER_LIT, FEATHER_GREY)),
..Default::default()
};
let end = particle.shade(FEATHER_LIT);
let grey = FEATHER_GREY.to_srgba();
assert!(
(end.to_srgba().blue - grey.blue).abs() < 1e-3,
"arrived grey"
);
assert!(end.alpha() < 1e-6, "and gone at the end of life");
}
#[test]
fn gravity_brings_a_particle_back_down() {
let mut particle = Particle {
velocity: Vec2::new(0.0, 40.0),
gravity: Vec2::new(0.0, -90.0),
life: 2.0,
..Default::default()
};
let mut height = 0.0;
let mut peak: f32 = 0.0;
for _ in 0..60 {
particle.velocity += particle.gravity * (1.0 / 60.0);
height += particle.velocity.y * (1.0 / 60.0);
peak = peak.max(height);
}
assert!(peak > 0.0, "it went up first: {peak}");
assert!(height < peak, "and came back down: {height} under {peak}");
}
#[test]
fn drag_slows_a_particle_without_reversing_it() {
for drag in [0.5f32, 1.1, 2.4, 40.0] {
for dt in [1.0f32 / 240.0, 1.0 / 60.0, 0.5, 2.0] {
let mut velocity = Vec2::new(30.0, -12.0);
let before = velocity;
velocity *= (1.0 - drag * dt).clamp(0.0, 1.0);
assert!(
velocity.length() <= before.length() + 1e-6,
"drag {drag} at dt {dt} sped it up"
);
assert!(
velocity.x >= 0.0 && velocity.y <= 0.0,
"drag {drag} at dt {dt} turned it round: {velocity:?}"
);
}
}
}
#[test]
fn the_claws_alternate_and_fall_on_opposite_sides() {
assert_eq!(Foot::Left.other(), Foot::Right);
assert_eq!(Foot::Right.other(), Foot::Left);
assert_eq!(
Foot::Left.other().other(),
Foot::Left,
"two steps is a cycle"
);
assert!(
Foot::Left.side() * Foot::Right.side() < 0.0,
"the two claws fall on the same side of the stride"
);
}
#[test]
fn a_bank_starts_on_the_sand_and_finishes_in_the_gate() {
let (from, to) = (Vec2::new(-120.0, 30.0), Vec2::new(64.0, 64.0));
let (start, start_scale) = arc(from, to, 12.0, 0.0);
assert_eq!(start, from);
assert!((start_scale - 1.0).abs() < 1e-6, "full size on the sand");
let (end, end_scale) = arc(from, to, 12.0, 1.0);
assert!(end.distance(to) < 1e-3, "{end:?} vs {to:?}");
assert!(end_scale < 0.35, "and all but gone: {end_scale}");
let (middle, _) = arc(from, to, 12.0, 0.5);
assert!(
middle.y > from.lerp(to, 0.5).y,
"off the ground in between: {middle:?}"
);
}
#[test]
fn a_bank_travels_in_a_straight_line() {
let (from, to) = (Vec2::new(0.0, 0.0), Vec2::new(400.0, 200.0));
for step in 0..=10 {
let (at, _) = arc(from, to, 12.0, step as f32 / 10.0);
let lift = (step as f32 / 10.0 * std::f32::consts::PI).sin() * 12.0;
assert!(
(at.y - lift - at.x * 0.5).abs() < 1e-3,
"bowed off the line at {step}: {at:?}"
);
}
}
#[test]
fn a_long_sweep_stays_under_the_cap_and_over_the_walls() {
let (from, to) = (Vec2::new(-400.0, -300.0), Vec2::new(400.0, 300.0));
for step in 0..=20 {
let progress = step as f32 / 20.0;
let (at, _) = sweep_arc(from, to, progress);
let (flat, _) = arc(from, to, 0.0, progress);
assert!(
at.y - flat.y <= TILE * 1.2 + 1e-3,
"too high at {step}: {at:?}"
);
assert!(at.z > layout::z::WALL, "under the fences at {step}");
}
}
#[test]
fn trauma_is_capped_and_drains_away() {
let mut trauma = Trauma::default();
for _ in 0..8 {
trauma.add(0.55);
}
assert_eq!(trauma.0, Trauma::MAX, "the pool has a ceiling");
let thrown = trauma.offset(0.016, 0.4);
assert!(thrown.length() > 0.0, "a full pool moves the camera");
assert!(
thrown.length() <= Trauma::THROW * std::f32::consts::SQRT_2,
"and never further than the throw allows: {thrown:?}"
);
for _ in 0..200 {
trauma.offset(0.016, 0.4);
}
assert_eq!(trauma.0, 0.0, "and it comes to rest");
assert_eq!(trauma.offset(0.016, 0.4), Vec2::ZERO);
}
}