use super::{WALL_COLOR, image_sprite, on_board, z};
use crate::app::Sim;
use crate::app::art::Art;
use crate::app::layout::{self, TILE};
use crate::app::palette;
use crate::sim::{TileKind, castle_tier};
use bevy::prelude::*;
#[derive(Component)]
pub struct CastleSprite {
x: u8,
y: u8,
tier: u8,
owner: u8,
}
pub fn sync_castles(
mut commands: Commands,
sim: Res<Sim>,
art: Res<Art>,
mut covered: Local<Vec<bool>>,
existing: Query<(Entity, &CastleSprite)>,
) {
let board = &sim.0;
covered.clear();
covered.resize(board.width() as usize * board.height() as usize, false);
for (entity, sprite) in &existing {
if !on_board(board, sprite.x, sprite.y) {
commands.entity(entity).despawn();
continue;
}
let idx = usize::from(board.index_of(sprite.x, sprite.y));
match board.tile_at(sprite.x, sprite.y) {
TileKind::Castle(owner)
if owner == sprite.owner
&& castle_tier(board.scores()[owner as usize]) == sprite.tier =>
{
covered[idx] = true;
}
TileKind::Castle(_)
| TileKind::Empty
| TileKind::Rock
| TileKind::Spawner(_)
| TileKind::Turnstile { .. }
| TileKind::Kelp
| TileKind::Pool => {
commands.entity(entity).despawn();
}
}
}
for (x, y, kind) in board.tiles() {
let idx = usize::from(board.index_of(x, y));
if covered[idx] {
continue;
}
let TileKind::Castle(owner) = kind else {
continue;
};
let tier = castle_tier(board.scores()[owner as usize]);
let color = palette::player_color(owner);
let pos = layout::tile_center(board, x, y);
let base_size = TILE * (0.55 + 0.12 * f32::from(tier));
commands
.spawn((
CastleSprite { x, y, tier, owner },
CastleKick(0.0),
image_sprite(&art.castle, color, Vec2::splat(base_size * 1.12)),
Transform::from_translation(pos.extend(z::TILE_FEATURE + 0.2)),
))
.with_children(|parent| {
parent.spawn((
image_sprite(
&art.shadow,
Color::srgba(1.0, 1.0, 1.0, 0.7),
Vec2::splat(base_size * 1.3),
),
Transform::from_translation(layout::SUN.extend(-0.3)),
));
if tier >= 1 {
parent.spawn((
image_sprite(&art.keep_ring, color.darker(0.12), Vec2::splat(TILE * 0.99)),
Transform::from_translation(Vec3::new(0.0, 0.0, -0.1)),
));
}
if tier >= 2 {
for side in [-1.0, 1.0] {
parent.spawn((
image_sprite(
&art.turret,
color.lighter(0.10),
Vec2::splat(TILE * 0.28),
),
Transform::from_translation(Vec3::new(
side * base_size * 0.42,
base_size * 0.42,
0.1,
)),
));
}
}
if tier >= 3 {
parent.spawn((
image_sprite(&art.moat, Color::WHITE, Vec2::splat(TILE * 1.06)),
Transform::from_translation(Vec3::new(0.0, 0.0, -0.2)),
));
}
let pole_height = TILE * 0.34;
let pole_top = base_size * 0.5 + pole_height;
parent.spawn((
Sprite::from_color(WALL_COLOR, Vec2::new(2.5, pole_height)),
Transform::from_translation(Vec3::new(
-TILE * 0.06,
base_size * 0.5 + pole_height / 2.0,
0.3,
)),
));
parent.spawn((
Pennant,
Sprite::from_color(color.lighter(0.22), Vec2::new(TILE * 0.20, TILE * 0.12)),
Transform::from_translation(Vec3::new(
-TILE * 0.06 + TILE * 0.11,
pole_top - TILE * 0.05,
0.3,
)),
));
});
}
}
#[derive(Component)]
pub struct Pennant;
#[derive(Component)]
pub struct CastleKick(pub f32);
const FLIGHT: f32 = 1.1;
const RISE: f32 = 0.30;
#[derive(Resource, Default)]
pub struct CastleFlight {
left: f32,
from: Vec<Flight>,
}
pub struct Flight {
tile: (u8, u8),
from: Vec2,
}
#[derive(Default)]
pub struct Detector {
ticks: u64,
held: Vec<Held>,
}
impl Detector {
fn is_fresh(&self, board: &crate::sim::Board) -> bool {
board.ticks() < self.ticks
|| self
.held
.iter()
.any(|held| !on_board(board, held.x, held.y))
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct Held {
x: u8,
y: u8,
owner: u8,
}
fn hop(from: Vec2, home: Vec2, progress: f32) -> (Vec2, f32) {
let eased = progress * progress * (3.0 - 2.0 * progress);
let height = (progress * std::f32::consts::PI).sin();
(from.lerp(home, eased), 1.0 + RISE * height)
}
pub fn fly_castles(
time: Res<Time>,
sim: Res<Sim>,
settings: Res<crate::app::settings::GameSettings>,
mut flight: ResMut<CastleFlight>,
mut before: Local<Detector>,
mut now: Local<Vec<Held>>,
mut castles: Query<(&CastleSprite, &mut Transform)>,
) {
let board = &sim.0;
if before.is_fresh(board) {
before.held.clear();
flight.from.clear();
flight.left = 0.0;
}
before.ticks = board.ticks();
now.clear();
now.extend(board.tiles().filter_map(|(x, y, kind)| match kind {
TileKind::Castle(owner) => Some(Held { x, y, owner }),
TileKind::Empty
| TileKind::Rock
| TileKind::Spawner(_)
| TileKind::Turnstile { .. }
| TileKind::Kelp
| TileKind::Pool => None,
}));
if swapped(&before.held, &now) && !settings.reduced_motion {
flight.from.clear();
let mut old = before.held.clone();
for held in now.iter() {
if let Some(at) = old.iter().position(|was| was.owner == held.owner) {
let was = old.remove(at);
flight.from.push(Flight {
tile: (held.x, held.y),
from: layout::tile_center(board, was.x, was.y),
});
}
}
flight.left = FLIGHT;
}
std::mem::swap(&mut before.held, &mut now);
if flight.left <= 0.0 {
return;
}
flight.left = (flight.left - time.delta_secs()).max(0.0);
let progress = 1.0 - flight.left / FLIGHT;
for (sprite, mut transform) in &mut castles {
let home = layout::tile_center(board, sprite.x, sprite.y);
let from = flight
.from
.iter()
.find(|flight| flight.tile == (sprite.x, sprite.y))
.map_or(home, |flight| flight.from);
let (at, scale) = hop(from, home, progress);
transform.translation.x = at.x;
transform.translation.y = at.y;
transform.scale = Vec3::splat(scale);
}
if flight.left == 0.0 {
flight.from.clear();
for (sprite, mut transform) in &mut castles {
let home = layout::tile_center(board, sprite.x, sprite.y);
transform.translation = home.extend(transform.translation.z);
transform.scale = Vec3::ONE;
}
}
}
fn swapped(before: &[Held], now: &[Held]) -> bool {
if before.len() < 2 || before.len() != now.len() || before == now {
return false;
}
let tiles = |list: &[Held]| {
let mut out: Vec<(u8, u8)> = list.iter().map(|held| (held.x, held.y)).collect();
out.sort_unstable();
out
};
let owners = |list: &[Held]| {
let mut out: Vec<u8> = list.iter().map(|held| held.owner).collect();
out.sort_unstable();
out
};
tiles(before) == tiles(now) && owners(before) == owners(now)
}
fn gains<'a>(
events: impl IntoIterator<Item = &'a crate::app::sim_events::SimEvent>,
) -> [u32; crate::sim::MAX_PLAYERS] {
use crate::app::sim_events::SimEvent;
let mut gained = [0u32; crate::sim::MAX_PLAYERS];
for event in events {
if let SimEvent::CrabBanked { owner, value, .. } = event
&& let Some(total) = gained.get_mut(usize::from(*owner))
{
*total = total.saturating_add(*value);
}
}
gained
}
pub fn kick_castles(
mut commands: Commands,
mut events: MessageReader<crate::app::sim_events::SimEvent>,
sim: Res<Sim>,
time: Res<Time>,
settings: Res<crate::app::settings::GameSettings>,
mut castles: Query<(&CastleSprite, &mut CastleKick, &mut Transform)>,
) {
let board = &sim.0;
let calm = settings.reduced_motion;
let gained = gains(events.read());
for (sprite, mut kick, _) in &mut castles {
if !on_board(board, sprite.x, sprite.y) {
continue;
}
let TileKind::Castle(owner) = board.tile_at(sprite.x, sprite.y) else {
continue;
};
let Some(&points) = gained.get(usize::from(owner)) else {
continue;
};
if points == 0 {
continue;
}
kick.0 = if calm { 0.0 } else { 1.0 };
crate::app::effects::score_pip(
&mut commands,
format!("+{points}"),
layout::tile_center(board, sprite.x, sprite.y) + Vec2::new(0.0, TILE * 0.35),
palette::player_color(owner).lighter(0.15),
);
}
let dt = time.delta_secs();
for (_, mut kick, mut transform) in &mut castles {
if kick.0 <= 0.0 {
continue;
}
kick.0 = (kick.0 - dt * 3.2).max(0.0);
let wobble = 1.0 + 0.20 * kick.0 * (kick.0 * std::f32::consts::PI * 2.0).sin().abs();
transform.scale = Vec3::splat(wobble);
}
}
#[allow(clippy::too_many_arguments)]
pub fn cheer_tier_ups(
mut commands: Commands,
mut events: MessageReader<crate::app::sim_events::SimEvent>,
sim: Res<Sim>,
art: Res<Art>,
settings: Res<crate::app::settings::GameSettings>,
mut rng: ResMut<crate::app::effects::VisualRng>,
mut trauma: ResMut<crate::app::effects::Trauma>,
mut castles: Query<(&CastleSprite, &mut CastleKick)>,
) {
use crate::app::effects::{Burst, burst, ring};
use crate::app::sim_events::SimEvent;
let board = &sim.0;
for event in events.read() {
let SimEvent::TierUp { owner } = event else {
continue;
};
if settings.reduced_motion {
continue;
}
for (sprite, mut kick) in &mut castles {
if sprite.owner != *owner || !on_board(board, sprite.x, sprite.y) {
continue;
}
let at = layout::tile_center(board, sprite.x, sprite.y);
let color = palette::player_color(*owner);
ring(
&mut commands,
&art,
at,
color.lighter(0.25),
TILE * 0.8,
2.6,
0.55,
);
burst(
&mut commands,
&mut rng,
&Burst {
image: art.puff.clone(),
pos: at,
color: Color::srgba(0.95, 0.9, 0.76, 0.9),
count: 9,
size: 15.0,
speed: 62.0,
gravity: 55.0,
},
);
for _ in 0..4 {
crate::app::effects::glint(&mut commands, &mut rng, &art, at, color.lighter(0.4));
}
kick.0 = 1.0;
}
trauma.add(0.2);
}
}
pub fn wave_pennants(time: Res<Time>, mut flags: Query<(Entity, &mut Transform), With<Pennant>>) {
let t = time.elapsed_secs();
for (entity, mut transform) in &mut flags {
let phase = f32::from(entity.to_bits() as u16 % 17);
transform.scale.x = 1.0 + (t * 5.0 + phase).sin() * 0.12;
transform.rotation = Quat::from_rotation_z((t * 3.0 + phase).sin() * 0.08);
}
}
#[cfg(test)]
mod tests {
use super::{gains, hop};
use crate::app::sim_events::SimEvent;
use crate::sim::{CrabKind, MAX_PLAYERS};
use bevy::prelude::*;
fn bank(owner: u8, value: u32) -> SimEvent {
SimEvent::CrabBanked {
id: 0,
owner,
pos: Vec2::ZERO,
keep: Vec2::ZERO,
value,
kind: CrabKind::Common,
}
}
#[test]
fn a_frame_of_banks_floats_one_number_and_it_is_the_total() {
assert_eq!(gains([]), [0; MAX_PLAYERS], "a quiet frame gains nobody");
assert_eq!(gains(&[bank(1, 3)])[1], 3, "one crab is worth its own");
let stream: Vec<SimEvent> = (0..8).map(|_| bank(1, 1)).collect();
assert_eq!(
gains(&stream)[1],
8,
"eight into one keep is eight, not eight ones"
);
assert_eq!(gains(&[bank(2, 50), bank(2, 1)])[2], 51);
}
#[test]
fn every_seat_is_counted_over_its_own_keep() {
let frame: Vec<SimEvent> = vec![bank(0, 1), bank(3, 2), bank(0, 4), bank(3, 1)];
let gained = gains(&frame);
assert_eq!(gained[0], 5, "two crabs home");
assert_eq!(gained[3], 3);
assert_eq!(
gained[1], 0,
"and a seat that banked nothing floats nothing"
);
assert_eq!(gained.iter().sum::<u32>(), 8, "nothing counted twice");
let stray = gains(&[bank(MAX_PLAYERS as u8, 9), bank(u8::MAX, 9)]);
assert_eq!(stray, [0; MAX_PLAYERS], "no seat, no number");
}
#[test]
fn a_hop_starts_and_finishes_on_the_sand() {
let from = Vec2::new(-100.0, 40.0);
let home = Vec2::new(220.0, -60.0);
let (start, start_scale) = hop(from, home, 0.0);
assert_eq!(start, from);
assert!((start_scale - 1.0).abs() < 1e-6);
let (end, end_scale) = hop(from, home, 1.0);
assert!(end.distance(home) < 1e-3, "{end:?} vs {home:?}");
assert!((end_scale - 1.0).abs() < 1e-6);
}
#[test]
fn only_a_reshuffle_counts_as_a_swap() {
let held = |x, y, owner| super::Held { x, y, owner };
let a = vec![held(1, 1, 0), held(5, 1, 1)];
let swapped_pair = vec![held(1, 1, 1), held(5, 1, 0)];
assert!(super::swapped(&a, &swapped_pair));
assert!(!super::swapped(&a, &a), "nothing moved");
assert!(!super::swapped(&[], &a), "the first frame is not a swap");
assert!(
!super::swapped(&a, &[held(1, 1, 0), held(5, 1, 1), held(9, 1, 2)]),
"a castle was built"
);
assert!(
!super::swapped(&a, &[held(1, 1, 0), held(7, 1, 1)]),
"a castle moved tile, which the sim never does"
);
assert!(
!super::swapped(&a, &[held(1, 1, 0), held(5, 1, 2)]),
"a different owner turned up"
);
assert!(
!super::swapped(&[held(1, 1, 0)], &[held(1, 1, 1)]),
"one castle cannot swap with itself"
);
}
#[test]
fn a_new_round_forgets_the_last_layout() {
let held = |x, y, owner| super::Held { x, y, owner };
let mut board = crate::sim::Board::new(12, 9, 1);
for _ in 0..50 {
board.tick_idle();
}
let detector = super::Detector {
ticks: board.ticks(),
held: vec![held(1, 1, 0), held(10, 7, 1)],
};
assert!(!detector.is_fresh(&board), "the same board, ticking on");
let next_round = crate::sim::Board::new(12, 9, 2);
assert!(detector.is_fresh(&next_round), "the clock rolled back");
let mut smaller = crate::sim::Board::new(9, 7, 1);
for _ in 0..80 {
smaller.tick_idle();
}
assert!(
detector.is_fresh(&smaller),
"a castle remembered off this board says the board changed"
);
}
#[test]
fn a_hop_travels_in_a_straight_line() {
let from = Vec2::new(0.0, 0.0);
let home = Vec2::new(400.0, 200.0);
for step in 0..=10 {
let progress = step as f32 / 10.0;
let (at, _) = hop(from, home, progress);
assert!((at.y - at.x * 0.5).abs() < 1e-3, "{at:?} at {progress}");
}
assert!(hop(from, home, 0.5).1 > 1.0);
}
}