use crate::app::art::Art;
use crate::app::layout::{self, TILE};
use crate::app::palette;
use crate::app::sim_events::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>,
}
#[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>,
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,
age: 0.0,
life: 0.5,
}
}
}
impl Particle {
fn strength(&self) -> f32 {
let rise = self.fade_in * self.life;
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()
},
Sprite {
image: spec.image.clone(),
color: spec.color,
custom_size: Some(Vec2::splat(spec.size * rng.range(0.7, 1.3))),
..default()
},
Transform::from_translation(spec.pos.extend(layout::z::PARTICLE)),
));
}
}
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()
},
Sprite {
image: art.feather.clone(),
color: FEATHER_LIT,
custom_size: Some(Vec2::splat(rng.range(11.0, 18.0))),
..default()
},
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()
},
Sprite {
image: art.gull_fly.clone(),
custom_size: Some(Vec2::splat(TILE * 0.78)),
..default()
},
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()
},
Sprite {
image: art.star.clone(),
color: Color::srgba(1.0, 0.92, 0.45, 0.9),
custom_size: Some(Vec2::splat(rng.range(6.0, 11.0))),
..default()
},
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()
},
Sprite {
image: art.star.clone(),
color: color.lighter(rng.range(0.0, 0.2)),
custom_size: Some(Vec2::splat(rng.range(8.0, 16.0))),
..default()
},
Transform::from_translation(Vec3::new(x, y, layout::z::CONFETTI)),
));
}
}
#[derive(Component)]
pub struct Hop {
from: Vec2,
to: Vec2,
age: f32,
span: f32,
lift: f32,
shell: Color,
launched: bool,
held: f32,
}
const HOP: f32 = 0.19;
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;
#[allow(clippy::too_many_arguments)]
pub fn bank_hop(
commands: &mut Commands,
art: &Art,
id: u32,
from: Vec2,
to: Vec2,
kind: crate::sim::CrabKind,
handed: crate::sim::Handedness,
swept: Option<usize>,
) {
use crate::app::creatures::{claw_color, mirrored, shade_of, shell_color, sprite_side};
let side = Vec2::splat(sprite_side(kind));
let flip_y = mirrored(handed);
let shell = shell_color(kind, shade_of(id));
let hop = match swept {
None => Hop {
from,
to,
age: 0.0,
span: HOP,
lift: 12.0,
shell,
launched: true,
held: 0.0,
},
Some(slot) => Hop {
from,
to,
age: -SWEEP_STAGGER * slot as f32,
span: SWEEP_HOP,
lift: from.distance(to) * 0.35,
shell,
launched: false,
held: 0.0,
},
};
commands
.spawn((
hop,
Sprite {
image: art.crab.clone(),
color: shell,
custom_size: Some(side),
flip_y,
..default()
},
Transform::from_translation(from.extend(layout::z::CREATURE + 0.1)),
))
.with_child((
Sprite {
image: art.claw.clone(),
color: claw_color(handed),
custom_size: Some(side),
flip_y,
..default()
},
Transform::from_translation(Vec3::new(0.0, 0.0, 0.01)),
));
}
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,
)
}
pub fn advance_hops(
time: Res<Time>,
mut commands: Commands,
art: Res<Art>,
mut rng: ResMut<VisualRng>,
announcer: Res<crate::app::announce::Announcer>,
mut hops: Query<(Entity, &mut Hop, &mut Transform, &mut Sprite)>,
) {
let dt = time.delta_secs();
for (entity, mut hop, mut transform, mut sprite) in &mut hops {
if !hop.launched {
let glow = 0.45 + 0.35 * (hop.held * 9.0).sin();
sprite.color = hop.shell.mix(&palette::GOLD, glow);
let held = hop.held < SWEEP_MIN_HOLD || announcer.holds_the_board();
if held && hop.held < SWEEP_MAX_HOLD {
hop.held += dt;
continue;
}
hop.age += dt;
if hop.age < 0.0 {
continue;
}
hop.launched = true;
sprite.color = 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.span).clamp(0.0, 1.0);
if progress >= 1.0 {
commands.entity(entity).despawn();
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;
}
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);
}
}
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,
sim: Res<crate::app::Sim>,
art: Res<Art>,
settings: Res<crate::app::settings::GameSettings>,
mut rng: ResMut<VisualRng>,
mut wet: Local<bevy::platform::collections::HashMap<(bool, u32), bool>>,
mut seen: Local<bevy::platform::collections::HashSet<(bool, u32)>>,
) {
let board = &sim.0;
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>,
mut since: Local<f32>,
) {
let Some((_, x, y)) = sim.0.golden_call() else {
*since = GOLDEN_RING_EVERY;
return;
};
*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,
sim: Res<crate::app::Sim>,
art: Res<Art>,
settings: Res<crate::app::settings::GameSettings>,
mut rng: ResMut<VisualRng>,
mut footfalls: Local<bevy::platform::collections::HashMap<u32, Footfall>>,
mut seen: Local<bevy::platform::collections::HashSet<u32>>,
) {
if settings.reduced_motion {
return;
}
const STRIDE: f32 = 20.0;
let board = &sim.0;
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; }
commands.spawn((
Particle {
life: rng.range(1.4, 2.2),
fade_in: 0.05,
..default()
},
Sprite::from_color(
Color::srgba(0.45, 0.36, 0.24, 0.9),
Vec2::splat(rng.range(3.0, 4.2)),
),
Transform::from_translation((pos + side).extend(layout::z::WET + 0.05)),
));
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()
},
Sprite {
image: art.puff.clone(),
color: SAND_DRY,
custom_size: Some(Vec2::splat(rng.range(5.0, 8.0))),
..default()
},
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>,
);
pub fn update_particles(
time: Res<Time>,
mut commands: Commands,
mut particles: Query<ParticleParts>,
) {
let dt = time.delta_secs();
for (entity, mut particle, mut transform, sprite, text_color) 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);
let scale = (1.0 + particle.grow * particle.age).max(0.05);
transform.scale = Vec3::splat(particle.size * scale);
if let Some(mut sprite) = sprite {
sprite.color = particle.shade(sprite.color);
}
if let Some(mut color) = text_color {
color.0 = particle.shade(color.0);
}
}
}
pub fn moment_effects(
fx: Fx,
mut events: MessageReader<SimEvent>,
cursors: Query<&crate::app::cursor::Cursor>,
mut trauma: ResMut<Trauma>,
) {
let Fx {
mut commands,
art,
mut rng,
settings,
} = fx;
let calm = settings.reduced_motion;
let mut sweeps = 0usize;
for event in events.read() {
if calm
&& !matches!(
event,
SimEvent::CastleRaided { .. }
| SimEvent::GullShooed { .. }
| SimEvent::GullFlewOff { .. }
)
{
continue;
}
match event {
SimEvent::CrabBanked {
id,
pos,
keep,
kind,
handed,
..
} => {
let swept = (pos.distance(*keep) > SWEPT_FROM).then(|| {
sweeps += 1;
sweeps - 1
});
bank_hop(&mut commands, &art, *id, *pos, *keep, *kind, *handed, swept);
}
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 } => {
commands.spawn((
Particle {
grow: 1.1,
life: 0.5,
fade_in: 0.0,
..default()
},
Sprite {
image: art.sign_paint.clone(),
color: palette::player_color(*owner).lighter(0.08),
custom_size: Some(Vec2::splat(TILE * 0.94)),
..default()
},
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()
},
Sprite {
image: art.puff.clone(),
color: Color::srgba(1.0, 1.0, 1.0, 1.0),
custom_size: Some(Vec2::splat(TILE * 1.1)),
..default()
},
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::seated_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::seated_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::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::GullArrived
| SimEvent::GullTookOff
| SimEvent::TierUp { .. }
| SimEvent::TideEventFired { .. }
| SimEvent::LureStarted { .. }
| SimEvent::WheelLocked { .. }
| SimEvent::RoundEnded => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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 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);
}
}