use super::{OnTile, clear_stale, image_sprite, on_board, z};
use crate::app::Sim;
use crate::app::art::Art;
use crate::app::layout::{self, SIGN_SIZE};
use crate::app::palette;
use crate::sim::{Direction, SignpostHealth};
use bevy::prelude::*;
#[derive(Component)]
pub struct SignpostSprite {
x: u8,
y: u8,
dir: Direction,
owner: u8,
age: f32,
planted: u64,
}
impl OnTile for SignpostSprite {
fn tile(&self) -> (u8, u8) {
(self.x, self.y)
}
}
#[derive(Component)]
pub struct SignpostShadow;
#[derive(Component)]
pub struct SignpostPaint;
#[derive(Component)]
pub struct SignpostHalo;
const HALO_SCALE: f32 = 1.4;
const WOBBLE: f32 = 0.12;
const PLANT_POP: f32 = 0.17;
fn plant_pop(age: f32) -> f32 {
if age >= PLANT_POP {
return 1.0;
}
let t = age / PLANT_POP;
1.0 + 0.35 * (1.0 - t) * (t * std::f32::consts::PI * 1.5).cos()
}
fn post_alpha(fade: f32) -> f32 {
0.7 + 0.3 * fade
}
fn wither(fade: f32) -> f32 {
0.86 + 0.14 * fade
}
pub fn sync_signposts(
mut commands: Commands,
sim: Res<Sim>,
art: Res<Art>,
mut covered: Local<Vec<bool>>,
existing: Query<(Entity, &SignpostSprite)>,
) {
let board = &sim.0;
clear_stale(&mut commands, board, &mut covered, existing, |sprite| {
board
.signpost_at(sprite.x, sprite.y)
.is_some_and(|sp| sp.dir == sprite.dir && sp.owner == sprite.owner)
});
for (x, y, _) in board.tiles() {
let idx = usize::from(board.index_of(x, y));
if covered[idx] {
continue;
}
let Some(sp) = board.signpost_at(x, y) else {
continue;
};
spawn_signpost(&mut commands, &art, board, (x, y), sp);
}
}
pub(crate) fn spawn_signpost(
commands: &mut Commands,
art: &Art,
board: &crate::sim::Board,
(x, y): (u8, u8),
sp: crate::sim::Signpost,
) -> Entity {
let pos = layout::tile_center(board, x, y);
commands
.spawn((
SignpostSprite {
x,
y,
dir: sp.dir,
owner: sp.owner,
age: 0.0,
planted: sp.placed,
},
image_sprite(&art.sign_board, Color::WHITE, Vec2::splat(SIGN_SIZE)),
Transform::from_translation(pos.extend(z::SIGNPOST))
.with_rotation(layout::dir_rotation(sp.dir)),
))
.with_children(|parent| {
parent.spawn((
SignpostHalo,
image_sprite(&art.sign_shape, palette::GOLD, Vec2::splat(SIGN_SIZE)),
Transform::from_translation(Vec3::new(0.0, 0.0, -0.04))
.with_scale(Vec3::splat(HALO_SCALE)),
Visibility::Hidden,
));
parent.spawn((
SignpostShadow,
image_sprite(&art.sign_shape, SIGN_SHADOW, Vec2::splat(SIGN_SIZE)),
Transform::from_translation(Vec3::new(0.0, 0.0, -0.05)),
));
parent.spawn((
SignpostPaint,
image_sprite(
&art.sign_paint,
palette::seat_paint(sp.owner),
Vec2::splat(SIGN_SIZE),
),
Transform::from_translation(Vec3::new(0.0, 0.0, 0.01)),
));
if board.rules().claw_hands.is_some()
&& let Some(handed) = crate::sim::claw_of(sp.owner)
{
parent.spawn(claw_badge(art, sp.dir, handed));
}
})
.id()
}
fn claw_badge(art: &Art, dir: Direction, handed: crate::sim::Handedness) -> impl Bundle {
let undo = layout::dir_rotation(dir).inverse();
let side = Vec2::splat(SIGN_SIZE * CLAW_BADGE);
let corner = Vec3::new(SIGN_SIZE * 0.34, -SIGN_SIZE * 0.30, 0.03);
let face_up = layout::dir_rotation(Direction::Up);
let mirrored = crate::app::creatures::mirrored(handed);
(
SignpostClaw,
Sprite {
image: art.crab.clone(),
color: palette::PARCHMENT,
custom_size: Some(side),
flip_y: mirrored,
..default()
},
Transform::from_translation(undo * corner).with_rotation(undo * face_up),
Children::spawn_one((
Sprite {
image: art.claw.clone(),
color: crate::app::creatures::claw_color(handed, true),
custom_size: Some(side),
flip_y: mirrored,
..default()
},
Transform::from_translation(Vec3::new(0.0, 0.0, 0.01)),
)),
)
}
#[derive(Component)]
pub struct SignpostClaw;
const CLAW_BADGE: f32 = 0.5;
const SIGN_SHADOW: Color = Color::srgba(0.14, 0.10, 0.06, 0.34);
type ShadowOnly = (
With<SignpostShadow>,
Without<SignpostSprite>,
Without<SignpostPaint>,
Without<SignpostHalo>,
);
type PaintOnly = (
With<SignpostPaint>,
Without<SignpostSprite>,
Without<SignpostShadow>,
Without<SignpostHalo>,
);
type HaloOnly = (
With<SignpostHalo>,
Without<SignpostSprite>,
Without<SignpostShadow>,
Without<SignpostPaint>,
);
#[derive(bevy::ecs::system::SystemParam)]
pub struct PostParts<'w, 's> {
shadows: Query<'w, 's, (&'static mut Sprite, &'static mut Transform), ShadowOnly>,
paints: Query<'w, 's, &'static mut Sprite, PaintOnly>,
halos: Query<'w, 's, (&'static mut Sprite, &'static mut Visibility), HaloOnly>,
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct PostClocks<'w> {
time: Res<'w, Time>,
round: Res<'w, crate::app::round_state::RoundClock>,
}
pub fn dress_signposts(
clocks: PostClocks,
sim: Res<Sim>,
art: Res<Art>,
settings: Res<crate::app::settings::GameSettings>,
seating: crate::app::seating::Seating,
mut posts: Query<(&mut SignpostSprite, &mut Sprite, &mut Transform, &Children)>,
parts: PostParts,
) {
use crate::app::side_panels::next_to_go_pulse;
let PostParts {
mut shadows,
mut paints,
mut halos,
} = parts;
let board = &sim.0;
let PostClocks { time, round } = clocks;
let dt = time.delta_secs();
let next: [Option<(u8, u8)>; crate::sim::MAX_PLAYERS] = std::array::from_fn(|seat| {
seating
.played_here(seat as u8)
.then(|| board.next_to_go(seat as u8))
.flatten()
});
let secs = round.0;
let pulse = next_to_go_pulse(secs, settings.reduced_motion);
for (mut post, mut sprite, mut transform, children) in &mut posts {
post.age += dt;
if !on_board(board, post.x, post.y) {
continue; }
let Some(sp) = board.signpost_at(post.x, post.y) else {
continue;
};
if sp.placed != post.planted {
post.planted = sp.placed;
post.age = 0.0;
}
let worn = sp.health == SignpostHealth::Worn;
let (board_art, paint_art) = if worn {
(&art.sign_board_worn, &art.sign_paint_worn)
} else {
(&art.sign_board, &art.sign_paint)
};
if sprite.image != *board_art {
sprite.image = board_art.clone();
}
let fade = board.signpost_fade(&sp);
let alpha = post_alpha(fade);
if sprite.color != Color::WHITE {
sprite.color = Color::WHITE;
}
let mut paint = palette::seat_paint(post.owner);
if worn {
paint = paint.darker(0.06);
}
let pop = if settings.reduced_motion {
1.0
} else {
plant_pop(post.age)
};
transform.scale = Vec3::splat(pop * wither(fade));
let marked = next.get(usize::from(post.owner)).copied().flatten() == Some((post.x, post.y));
let rock = match marked && !settings.reduced_motion {
true => WOBBLE * (secs * std::f32::consts::TAU * 2.4).sin(),
false => 0.0,
};
let rotation = layout::dir_rotation(post.dir) * Quat::from_rotation_z(rock);
if transform.rotation != rotation {
transform.rotation = rotation;
}
for child in children {
if let Ok((mut halo, mut shown)) = halos.get_mut(*child) {
let want = match marked {
true => Visibility::Inherited,
false => Visibility::Hidden,
};
shown.set_if_neq(want);
if marked {
layout::tint(&mut halo, palette::GOLD.with_alpha(pulse));
}
continue;
}
if let Ok(mut coat) = paints.get_mut(*child) {
if coat.image != *paint_art {
coat.image = paint_art.clone();
}
layout::tint(&mut coat, paint.with_alpha(alpha));
continue;
}
let Ok((mut shadow, mut shadow_tf)) = shadows.get_mut(*child) else {
continue;
};
layout::tint(&mut shadow, SIGN_SHADOW);
let scale = transform.scale.x.max(f32::EPSILON);
let local = transform.rotation.inverse() * (layout::SUN / scale).extend(0.0);
shadow_tf.translation = Vec3::new(local.x, local.y, -0.05);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_posts_paint_never_fades_past_seventy_percent() {
for step in 0..=10 {
let alpha = post_alpha(step as f32 / 10.0);
assert!(alpha >= 0.7 - 1e-6, "{alpha} at {step}");
}
}
#[test]
fn the_plant_hands_over_to_rest_without_a_step() {
assert!(plant_pop(0.0) > 1.3, "driven in big: {}", plant_pop(0.0));
let last = plant_pop(PLANT_POP - 1e-4);
assert!(
(last - 1.0).abs() < 0.01,
"the curve ends at {last}, and then snaps to 1.0"
);
assert_eq!(plant_pop(PLANT_POP), 1.0, "which is where rest begins");
assert_eq!(plant_pop(PLANT_POP * 4.0), 1.0, "and stays");
}
#[test]
fn the_plant_squashes_without_ever_turning_inside_out() {
let mut dipped = false;
for step in 0..=40 {
let pop = plant_pop(PLANT_POP * step as f32 / 40.0);
assert!(pop > 0.5, "collapsed to {pop} at step {step}");
assert!(pop < 1.4, "blew up to {pop} at step {step}");
dipped |= pop < 0.98;
}
assert!(dipped, "no squash at all: it only ever grows");
}
#[test]
fn a_post_never_fades_past_reading() {
for step in 0..=20 {
let alpha = post_alpha(step as f32 / 20.0);
assert!((0.55..=1.0).contains(&alpha), "{alpha} is off the scale");
}
assert_eq!(post_alpha(1.0), 1.0, "a fresh post is at full strength");
assert!(post_alpha(0.0) >= 0.55, "and a spent one is still legible");
}
#[test]
fn an_expiring_post_withers_but_does_not_shrivel() {
assert_eq!(wither(1.0), 1.0, "a fresh post is full size");
assert!(wither(0.0) >= 0.86, "and a spent one is only a little less");
let mut last = f32::MAX;
for step in 0..=20 {
let size = wither(1.0 - step as f32 / 20.0);
assert!(size <= last, "it grew back at step {step}");
last = size;
}
}
}