use super::{CreatureShadow, Tween, smoothing_alpha, tweened_pos, unturned};
use crate::app::art::Art;
use crate::app::effects;
use crate::app::layout::{self, TILE};
use crate::app::sim_events::{BoardMemory, BoardStamp, CurrentBoard};
use crate::sim::{Gull, GullState};
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
#[derive(Component)]
pub struct GullSprite(pub u32);
pub fn sync_gull_sprites(
current: CurrentBoard,
fx: effects::Fx,
mut live: Local<HashMap<u32, Gull>>,
mut before: Local<(BoardStamp, Vec<u32>)>,
existing: Query<(Entity, &GullSprite)>,
) {
let effects::Fx {
mut commands,
art,
mut rng,
settings,
} = fx;
let board = ¤t.sim.0;
let (last_board, seen) = &mut *before;
let new_board = last_board.is_new(¤t);
if new_board || board.ticks() == 0 {
seen.clear();
seen.extend(board.gulls().iter().map(|g| g.id));
}
live.clear();
live.extend(board.gulls().iter().map(|g| (g.id, *g)));
for (entity, sprite) in &existing {
if live.remove(&sprite.0).is_none() {
commands.entity(entity).despawn();
}
}
for (&id, gull) in live.iter() {
let pos = layout::creature_pos(board, gull.tile, gull.dir, gull.progress);
if !settings.reduced_motion && !seen.contains(&id) {
kick_up(&mut commands, &mut rng, &art, pos);
}
spawn_gull(&mut commands, &art, board, gull);
}
seen.clear();
seen.extend(board.gulls().iter().map(|gull| gull.id));
}
pub(crate) fn spawn_gull(
commands: &mut Commands,
art: &Art,
board: &crate::sim::Board,
gull: &Gull,
) -> Entity {
let pos = layout::creature_pos(board, gull.tile, gull.dir, gull.progress);
commands
.spawn((
GullSprite(gull.id),
layout::image_sprite(&art.gull, Color::WHITE, Vec2::splat(TILE * 0.78)),
Transform::from_translation(pos.extend(layout::z::CREATURE + 0.2))
.with_rotation(layout::dir_rotation(gull.dir)),
))
.with_children(|parent| {
parent.spawn((
CreatureShadow,
layout::image_sprite(
&art.shadow,
Color::WHITE,
Vec2::new(TILE * 0.72, TILE * 0.54),
),
Transform::from_translation(Vec3::new(0.0, 0.0, -0.5)),
));
})
.id()
}
type GullBody = (
&'static GullSprite,
&'static mut Transform,
&'static mut Sprite,
&'static Children,
);
#[derive(bevy::ecs::system::SystemParam)]
pub struct GullSprites<'w, 's> {
bodies: Query<'w, 's, GullBody, Without<CreatureShadow>>,
shadows: Query<'w, 's, (&'static mut Transform, &'static mut Sprite), With<CreatureShadow>>,
}
pub fn interpolate_gulls(
current: CurrentBoard,
fx: effects::Fx,
tween: Tween,
mut by_id: Local<HashMap<u32, Gull>>,
mut aloft: Local<BoardMemory<u32, Flight>>,
gulls: GullSprites,
) {
let effects::Fx {
mut commands,
art,
mut rng,
settings,
} = fx;
let Tween {
time,
fixed_time,
mut watch,
round,
round_clock,
} = tween;
let GullSprites {
bodies: mut sprites,
mut shadows,
} = gulls;
let board = ¤t.sim.0;
let alpha = smoothing_alpha(board.ticks(), &mut watch, time.delta_secs(), &fixed_time);
let stopped = !round.moving(board);
by_id.clear();
by_id.extend(board.gulls().iter().map(|g| (g.id, *g)));
let aloft = aloft.of(¤t);
if board.ticks() == 0 {
aloft.clear();
}
aloft.retain(|id, _| by_id.contains_key(id));
let t = round_clock.0;
for (sprite, mut transform, mut image, children) in &mut sprites {
let Some(gull) = by_id.get(&sprite.0) else {
continue;
};
let prev = layout::pose_pos(board, gull.prev);
let curr = layout::pose_pos(board, gull.pose());
let pos = tweened_pos(prev, curr, alpha);
transform.translation = pos.extend(layout::z::CREATURE + 0.2);
let phase = f32::from(sprite.0 as u16 % 32);
let altitude = match gull.state {
GullState::Walking => {
aloft.remove(&sprite.0);
0.0
}
GullState::Flying { remaining } => {
let calm = settings.reduced_motion;
let flight = aloft.entry(gull.id).or_insert_with(|| {
if !calm {
kick_up(&mut commands, &mut rng, &art, pos);
}
Flight {
start: remaining,
altitude: 0.0,
descending: false,
}
});
let across = f32::from(gull.progress) / f32::from(crate::sim::SUBUNITS_PER_TILE);
flight.height(remaining, across)
}
};
let flying = altitude > 0.0 || matches!(gull.state, GullState::Flying { .. });
let beat = (t * 16.0 + phase).sin();
let wanted = match (flying, beat > 0.35) {
(true, true) => &art.gull_fly_b,
(true, false) => &art.gull_fly,
(false, _) => &art.gull,
};
if image.image != *wanted {
image.image = wanted.clone();
}
let (scale, waddle) = if flying {
(1.0 + 0.22 * altitude + beat * 0.05, 0.0)
} else if prev == curr || stopped {
(1.0, 0.0)
} else {
(1.0, (t * 9.0 + phase).sin() * 0.05)
};
transform.rotation = layout::dir_rotation(gull.dir) * Quat::from_rotation_z(waddle);
transform.scale = Vec3::splat(scale);
for child in children {
let Ok((mut shadow_tf, mut shadow_sprite)) = shadows.get_mut(*child) else {
continue;
};
let drift = Vec2::new((t * 2.1 + phase).cos() * 2.5, (t * 1.7 + phase).sin() * 2.5);
let drop = 26.0 * altitude;
let world = layout::SUN + Vec2::new(-drop * 0.5, -drop) + drift * altitude;
shadow_tf.translation = unturned(&transform, world / scale, -0.5);
shadow_tf.scale = Vec3::splat((1.0 - 0.25 * altitude) / scale);
layout::tint(
&mut shadow_sprite,
Color::srgba(1.0, 1.0, 1.0, 1.0 - 0.15 * altitude),
);
}
}
}
#[derive(Component)]
pub struct GullArrival {
at: u64,
tile: u16,
part: ArrivalPart,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ArrivalPart {
Bird,
Wings,
Oval,
}
const SHADOW_SWEEP: f32 = 0.45;
const SHADOW_BEHIND: f32 = 1.3;
const SHADOW_ALONG: f32 = 1.0;
const BIRD_TRAIL: f32 = 1.6;
const ARRIVAL_HEIGHT: f32 = 2.0;
#[derive(Clone, Copy, Debug)]
struct ArrivalPose {
shadow: Vec2,
bird: Vec2,
altitude: f32,
heading: f32,
}
fn arrival_pose(progress: f32, land: Vec2, facing: Vec2, tile: u16) -> ArrivalPose {
let along = if tile.is_multiple_of(2) { 1.0 } else { -1.0 };
let rest = land + layout::SUN;
let entry = rest - facing * TILE * SHADOW_BEHIND + facing.perp() * along * TILE * SHADOW_ALONG;
let shadow = entry.lerp(rest, effects::smoothstep(progress / SHADOW_SWEEP));
let altitude = ARRIVAL_HEIGHT * (1.0 - effects::smoothstep(progress)).powf(1.1);
let back = (entry - rest).normalize_or_zero();
let high = altitude / ARRIVAL_HEIGHT;
let bird = shadow - layout::SUN + back * TILE * BIRD_TRAIL * high;
ArrivalPose {
shadow,
bird,
altitude,
heading: (-back).to_angle(),
}
}
fn arrival_parts(calm: bool) -> &'static [ArrivalPart] {
match calm {
true => &[ArrivalPart::Oval],
false => &[ArrivalPart::Bird, ArrivalPart::Wings, ArrivalPart::Oval],
}
}
pub fn fly_in_gulls(
current: CurrentBoard,
fx: effects::Fx,
tween: Tween,
mut parts: Query<(Entity, &GullArrival, &mut Transform, &mut Sprite)>,
) {
let effects::Fx {
mut commands,
art,
rng: _,
settings,
} = fx;
let Tween {
time,
fixed_time,
mut watch,
round,
round_clock,
} = tween;
let board = ¤t.sim.0;
let over = round.ended(board);
let incoming = if over {
&[][..]
} else {
board.incoming_gulls()
};
let wanted = arrival_parts(settings.reduced_motion);
for (entity, part, _, _) in &parts {
let flying = incoming
.iter()
.any(|gull| gull.at == part.at && gull.tile == part.tile);
if !flying || !wanted.contains(&part.part) {
commands.entity(entity).despawn();
}
}
for gull in incoming {
for &part in wanted {
let drawn = parts
.iter()
.any(|(_, p, _, _)| p.at == gull.at && p.tile == gull.tile && p.part == part);
if drawn {
continue;
}
let (image, size) = match part {
ArrivalPart::Bird => (&art.gull_fly, Vec2::splat(TILE * 0.78)),
ArrivalPart::Wings => (&art.gull_shadow, Vec2::splat(TILE * 0.78 * 1.05)),
ArrivalPart::Oval => (&art.shadow, Vec2::new(TILE * 0.72, TILE * 0.54)),
};
commands.spawn((
GullArrival {
at: gull.at,
tile: gull.tile,
part,
},
layout::image_sprite(image, Color::NONE, size),
Transform::from_translation(Vec3::new(0.0, 0.0, layout::z::CREATURE)),
));
}
}
let alpha = smoothing_alpha(board.ticks(), &mut watch, time.delta_secs(), &fixed_time);
let now = board.ticks() as f32 - 1.0 + alpha;
let lead = crate::sim::GULL_LEAD as f32;
let t = round_clock.0;
for (_, part, mut transform, mut sprite) in &mut parts {
let Some(gull) = incoming
.iter()
.find(|gull| gull.at == part.at && gull.tile == part.tile)
else {
continue;
};
let progress = ((now - (gull.at as f32 - lead)) / lead).clamp(0.0, 1.0);
let land = layout::creature_pos(board, gull.tile, gull.dir, 0);
let facing = (layout::dir_rotation(gull.dir) * Vec3::X).truncate();
let landed_heading = facing.to_angle();
if settings.reduced_motion {
transform.translation = (land + layout::SUN).extend(layout::z::CREATURE - 0.4);
transform.rotation = Quat::from_rotation_z(landed_heading);
transform.scale = Vec3::ONE;
layout::tint(
&mut sprite,
Color::srgba(1.0, 1.0, 1.0, effects::smoothstep(progress)),
);
continue;
}
let pose = arrival_pose(progress, land, facing, gull.tile);
let altitude = pose.altitude;
let phase = f32::from(gull.tile % 32);
let beat = (t * 16.0 + phase).sin();
let upstroke = beat > 0.35;
let scale = 1.0 + 0.22 * altitude + beat * 0.05;
let turn = effects::smoothstep((progress - 0.75) / 0.25);
let swing = (landed_heading - pose.heading + std::f32::consts::PI)
.rem_euclid(std::f32::consts::TAU)
- std::f32::consts::PI;
let heading = Quat::from_rotation_z(pose.heading + swing * turn);
let drift = Vec2::new((t * 2.1 + phase).cos() * 2.5, (t * 1.7 + phase).sin() * 2.5);
let shadow = pose.shadow + drift * altitude;
let wings = effects::smoothstep(altitude / 0.7);
match part.part {
ArrivalPart::Bird => {
transform.translation = pose.bird.extend(layout::z::ARRIVING_GULL);
transform.scale = Vec3::splat(scale);
layout::tint(&mut sprite, Color::WHITE);
let wanted = if upstroke {
&art.gull_fly_b
} else {
&art.gull_fly
};
if sprite.image != *wanted {
sprite.image = wanted.clone();
}
}
ArrivalPart::Wings => {
transform.translation = shadow.extend(layout::z::CREATURE - 0.45);
transform.scale = Vec3::splat(scale);
layout::tint(&mut sprite, Color::srgba(1.0, 1.0, 1.0, 0.85 * wings));
let wanted = if upstroke {
&art.gull_shadow_b
} else {
&art.gull_shadow
};
if sprite.image != *wanted {
sprite.image = wanted.clone();
}
}
ArrivalPart::Oval => {
transform.translation = shadow.extend(layout::z::CREATURE - 0.4);
transform.scale = Vec3::splat(1.0 - 0.25 * altitude);
let oval = Color::srgba(1.0, 1.0, 1.0, (1.0 - 0.15 * altitude) * (1.0 - wings));
layout::tint(&mut sprite, oval);
}
}
transform.rotation = heading;
}
}
pub struct Flight {
start: u8,
altitude: f32,
descending: bool,
}
impl Flight {
fn height(&mut self, remaining: u8, across: f32) -> f32 {
if remaining <= 1 {
self.descending = true;
}
let climb = if remaining >= self.start { across } else { 1.0 };
let descend = if remaining <= 1 { 1.0 - across } else { 1.0 };
let wanted = climb.min(descend).clamp(0.0, 1.0);
self.altitude = if self.descending {
wanted.min(self.altitude)
} else {
wanted
};
self.altitude
}
}
fn kick_up(commands: &mut Commands, rng: &mut effects::VisualRng, art: &Art, pos: Vec2) {
effects::burst(
commands,
rng,
&effects::Burst {
image: art.puff.clone(),
pos,
color: Color::srgba(0.93, 0.87, 0.7, 0.75),
count: 5,
size: 13.0,
speed: 52.0,
gravity: 80.0,
},
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_gull_climbs_once_and_only_ever_comes_down() {
let mut flight = Flight {
start: 3,
altitude: 0.0,
descending: false,
};
assert_eq!(flight.height(3, 0.0), 0.0, "leaves the sand at nothing");
assert!((flight.height(3, 0.5) - 0.5).abs() < 1e-6, "climbing");
assert_eq!(flight.height(2, 0.5), 1.0, "level across the middle");
assert!(
(flight.height(1, 0.25) - 0.75).abs() < 1e-6,
"and coming down over the last tile, which is `remaining == 1`"
);
let low = flight.height(1, 0.9);
assert!(low < 0.15, "all but on the sand: {low}");
assert!(flight.height(1, 0.0) <= low, "went back up to {low}");
}
#[test]
fn a_gull_flying_in_casts_its_shadow_on_its_tile_first() {
use crate::sim::Direction;
let land = Vec2::new(100.0, -40.0);
for dir in [
Direction::Down,
Direction::Up,
Direction::Left,
Direction::Right,
] {
let facing = (layout::dir_rotation(dir) * Vec3::X).truncate();
for tile in [6u16, 7] {
let steps = 100;
let warned = (0..=steps)
.filter(|&step| {
let pose = arrival_pose(step as f32 / steps as f32, land, facing, tile);
let off = pose.shadow - land;
off.dot(facing) >= -TILE * 0.5 && off.length() <= TILE
})
.count();
assert!(
warned * 2 >= steps,
"{dir:?} tile {tile}: on the sand for {warned} of {steps}"
);
let half = arrival_pose(0.5, land, facing, tile);
assert!(
half.shadow.distance(land + layout::SUN) < 1.0,
"{dir:?}: settled on its tile by half way"
);
assert!(
half.bird.distance(land) > TILE * 0.5,
"{dir:?}: while the bird is still on its way"
);
let landed = arrival_pose(1.0, land, facing, tile);
assert!(
landed.bird.distance(land) < 1e-3,
"{dir:?}: lands on its tile"
);
assert!(landed.shadow.distance(land + layout::SUN) < 1e-3);
assert_eq!(landed.altitude, 0.0);
}
}
}
fn arriving(calm: bool) -> App {
use crate::app::{Screen, VersusPhase};
let mut app = App::new();
let mut board = crate::sim::Board::new(9, 7, 3);
board.rules_mut().gull_period = 500;
board.tick_idle();
assert_eq!(board.incoming_gulls().len(), 1, "one sent for");
app.insert_resource(crate::app::Sim(board));
app.init_resource::<crate::app::sim_events::BoardGeneration>();
app.insert_resource(Art::blank());
app.init_resource::<effects::VisualRng>();
app.insert_resource(crate::app::settings::GameSettings {
reduced_motion: calm,
..default()
});
app.init_resource::<Time>();
app.init_resource::<Time<Fixed>>();
app.init_resource::<crate::app::round_state::RoundClock>();
app.insert_resource(State::new(Screen::Versus));
app.insert_resource(State::new(VersusPhase::Running));
app.init_resource::<crate::app::Paused>();
app.init_resource::<crate::app::pause::PauseMenu>();
app.init_resource::<crate::app::net::Online>();
app.add_systems(Update, fly_in_gulls);
app
}
fn drawn_parts(app: &mut App) -> (Vec<bool>, Option<Vec3>) {
let world = app.world_mut();
let mut parts = [false; 3];
let mut oval = None;
for (arrival, place) in world.query::<(&GullArrival, &Transform)>().iter(world) {
let index = match arrival.part {
ArrivalPart::Bird => 0,
ArrivalPart::Wings => 1,
ArrivalPart::Oval => {
oval = Some(place.scale);
2
}
};
parts[index] = true;
}
(parts.to_vec(), oval)
}
fn set_calm(app: &mut App, calm: bool) {
app.world_mut()
.resource_mut::<crate::app::settings::GameSettings>()
.reduced_motion = calm;
}
#[test]
fn a_gull_flying_in_follows_the_motion_setting_mid_flight() {
let mut app = arriving(false);
app.update();
app.update();
assert_eq!(drawn_parts(&mut app).0, [true, true, true], "in flight");
set_calm(&mut app, true);
app.update();
app.update();
let (parts, oval) = drawn_parts(&mut app);
assert_eq!(parts, [false, false, true], "only the oval");
assert_eq!(oval, Some(Vec3::ONE), "at its full size");
set_calm(&mut app, false);
app.update();
app.update();
assert_eq!(drawn_parts(&mut app).0, [true, true, true], "flying again");
}
#[test]
fn a_gull_flying_in_when_the_round_ends_is_not_left_in_the_air() {
let mut app = arriving(false);
app.update();
app.update();
assert_eq!(drawn_parts(&mut app).0, [true, true, true], "in flight");
app.insert_resource(State::new(crate::app::VersusPhase::Over));
app.update();
app.update();
assert_eq!(drawn_parts(&mut app).0, [false, false, false], "gone");
}
}