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(Resource)]
pub struct VisualRng(u32);
impl Default for VisualRng {
fn default() -> Self {
VisualRng(0x9E37_79B9)
}
}
impl VisualRng {
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,
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,
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,
);
}
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) {
commands.spawn((
Particle {
velocity: Vec2::new(0.0, 46.0),
gravity: Vec2::new(0.0, -34.0),
life: 1.2,
fade_in: 0.0,
..default()
},
Text2d::new(text),
TextFont {
font_size: FontSize::Px(22.0),
..default()
},
TextColor(color),
Transform::from_translation(pos.extend(layout::z::PIP)),
));
}
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,
}
const HOP: f32 = 0.19;
pub fn bank_hop(
commands: &mut Commands,
art: &Art,
id: u32,
from: Vec2,
to: Vec2,
kind: crate::sim::CrabKind,
) {
let size = crate::app::creatures::body_size(kind);
commands.spawn((
Hop { from, to, age: 0.0 },
Sprite {
image: art.crab.clone(),
color: crate::app::creatures::shell_color(kind, crate::app::creatures::shade_of(id)),
custom_size: Some(size * 1.25),
..default()
},
Transform::from_translation(from.extend(layout::z::CREATURE + 0.1)),
));
}
fn arc(from: Vec2, to: Vec2, progress: f32) -> (Vec2, f32) {
let eased = progress * progress * (3.0 - 2.0 * progress);
let lift = (progress * std::f32::consts::PI).sin() * 12.0;
(
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>,
mut hops: Query<(Entity, &mut Hop, &mut Transform)>,
) {
let dt = time.delta_secs();
for (entity, mut hop, mut transform) in &mut hops {
hop.age += dt;
let progress = (hop.age / HOP).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, progress);
transform.translation.x = at.x;
transform.translation.y = at.y;
transform.scale = Vec3::splat(scale);
}
}
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));
}
#[allow(clippy::type_complexity)]
pub fn update_particles(
time: Res<Time>,
mut commands: Commands,
mut particles: Query<(
Entity,
&mut Particle,
&mut Transform,
Option<&mut Sprite>,
Option<&mut TextColor>,
)>,
) {
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(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);
}
}
}
#[allow(clippy::too_many_arguments)]
pub fn moment_effects(
mut commands: Commands,
mut events: MessageReader<SimEvent>,
art: Res<Art>,
cursors: Query<&crate::app::cursor::Cursor>,
settings: Res<crate::app::settings::GameSettings>,
mut rng: ResMut<VisualRng>,
mut trauma: ResMut<Trauma>,
) {
let calm = settings.reduced_motion;
for event in events.read() {
if calm
&& !matches!(
event,
SimEvent::CastleRaided { .. } | SimEvent::GullShooed { .. }
)
{
continue;
}
match event {
SimEvent::CrabBanked {
id,
pos,
keep,
kind,
..
} => bank_hop(&mut commands, &art, *id, *pos, *keep, *kind),
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.arrow.clone(),
color: palette::player_color(*owner).lighter(0.12),
custom_size: Some(Vec2::splat(TILE * 0.88)),
..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::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,
);
}
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::GullArrived
| SimEvent::GullTookOff
| SimEvent::TierUp { .. }
| SimEvent::TideEventFired { .. }
| SimEvent::RoundEnded => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[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, 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, 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, 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, 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);
}
}