use crate::app::art::Art;
use crate::app::layout::{self, TILE};
use crate::app::sim_events::SimEvent;
use bevy::prelude::*;
#[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(Component)]
pub struct Particle {
velocity: Vec2,
spin: f32,
grow: f32,
age: f32,
life: f32,
}
#[allow(clippy::too_many_arguments)]
pub fn burst(
commands: &mut Commands,
rng: &mut VisualRng,
image: &Handle<Image>,
pos: Vec2,
color: Color,
count: usize,
size: f32,
speed: f32,
) {
for _ in 0..count {
let angle = rng.range(0.0, std::f32::consts::TAU);
let velocity = Vec2::from_angle(angle) * rng.range(speed * 0.4, speed);
commands.spawn((
Particle {
velocity,
spin: rng.range(-4.0, 4.0),
grow: rng.range(0.2, 0.7),
age: 0.0,
life: rng.range(0.35, 0.6),
},
Sprite {
image: image.clone(),
color,
custom_size: Some(Vec2::splat(size * rng.range(0.7, 1.3))),
..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,
age: 0.0,
life: rng.range(0.4, 0.7),
},
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 score_pip(commands: &mut Commands, text: String, pos: Vec2, color: Color) {
commands.spawn((
Particle {
velocity: Vec2::new(0.0, 30.0),
spin: 0.0,
grow: 0.0,
age: 0.0,
life: 1.2,
},
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(-130.0, -70.0)),
spin: rng.range(-6.0, 6.0),
grow: 0.0,
age: 0.0,
life: rng.range(1.6, 3.0),
},
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)),
));
}
}
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, (Vec2, bool)>>,
mut seen: Local<Vec<u32>>,
) {
use crate::sim::TileKind;
if settings.reduced_motion {
return;
}
const STRIDE: f32 = 20.0;
let board = &sim.0;
seen.clear();
for crab in board.crabs() {
seen.push(crab.id);
let pos = layout::creature_pos(board, crab.tile, crab.dir, crab.progress);
let Some((last, left_foot)) = footfalls.get_mut(&crab.id) else {
footfalls.insert(crab.id, (pos, false));
continue;
};
let step = pos - *last;
if step.length() < STRIDE {
continue;
}
let side = step.normalize_or_zero().perp() * if *left_foot { 5.0 } else { -5.0 };
*last = pos;
*left_foot = !*left_foot;
let (x, y) = board.coords_u8(crab.tile);
if board.tile_at(x, y) != TileKind::Empty {
continue; }
commands.spawn((
Particle {
velocity: Vec2::ZERO,
spin: 0.0,
grow: 0.0,
age: 0.0,
life: rng.range(1.4, 2.2),
},
Sprite::from_color(
Color::srgba(0.45, 0.36, 0.24, 0.38),
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),
spin: rng.range(-3.0, 3.0),
grow: 0.5,
age: 0.0,
life: rng.range(0.25, 0.4),
},
Sprite {
image: art.puff.clone(),
color: Color::srgba(0.9, 0.82, 0.62, 0.55),
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 fade = 1.0 - particle.age / particle.life;
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 = sprite.color.with_alpha(fade);
}
if let Some(mut color) = text_color {
color.0 = color.0.with_alpha(fade);
}
}
}
pub fn moment_effects(
mut commands: Commands,
mut events: MessageReader<SimEvent>,
art: Res<Art>,
settings: Res<crate::app::settings::GameSettings>,
mut rng: ResMut<VisualRng>,
) {
let calm = settings.reduced_motion;
for event in events.read() {
if calm && !matches!(event, SimEvent::CastleRaided { .. }) {
continue;
}
match event {
SimEvent::CrabBanked { pos, .. } => {
burst(
&mut commands,
&mut rng,
&art.puff,
*pos,
Color::srgba(0.95, 0.88, 0.7, 0.8),
4,
14.0,
40.0,
);
}
SimEvent::CrabEaten { pos } => burst(
&mut commands,
&mut rng,
&art.puff,
*pos,
Color::srgba(0.85, 0.92, 0.98, 0.8),
5,
12.0,
55.0,
),
SimEvent::CrabSpawned { pos } => burst(
&mut commands,
&mut rng,
&art.puff,
*pos,
Color::srgba(0.9, 0.8, 0.6, 0.85),
3,
12.0,
35.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 {
velocity: Vec2::ZERO,
spin: 0.0,
grow: 1.6,
age: 0.0,
life: 0.3,
},
Sprite {
image: art.puff.clone(),
color: Color::srgba(1.0, 1.0, 1.0, 0.9),
custom_size: Some(Vec2::splat(TILE * 1.1)),
..default()
},
Transform::from_translation(pos.extend(layout::z::FLASH)),
));
burst(
&mut commands,
&mut rng,
&art.puff,
*pos,
Color::srgba(0.9, 0.8, 0.6, 0.85),
8,
16.0,
70.0,
);
score_pip(
&mut commands,
format!("-{lost}"),
*pos + Vec2::new(0.0, 10.0),
Color::srgb(0.96, 0.3, 0.25),
);
}
SimEvent::GullLanded { pos } => burst(
&mut commands,
&mut rng,
&art.puff,
*pos,
Color::srgba(0.92, 0.85, 0.68, 0.8),
4,
13.0,
45.0,
),
SimEvent::GullArrived
| SimEvent::GullTookOff
| SimEvent::SignpostsChanged { .. }
| SimEvent::TierUp { .. }
| SimEvent::TideEventFired { .. }
| SimEvent::SurgeStarted
| SimEvent::RoundEnded => {}
}
}
}