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::color::Mix;
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);
commands
.spawn((
CastleSprite { x, y, tier, owner },
CastleKick(0.0),
Transform::from_translation(pos.extend(z::TILE_FEATURE + 0.2)),
Visibility::default(),
))
.with_children(|parent| build_castle(parent, &art, tier, color));
}
}
const SAND: Color = Color::srgb(0.925, 0.84, 0.66);
pub fn castle_body(owner: Color) -> Color {
SAND.mix(&owner, 0.62)
}
struct Piece {
size: f32,
at: Vec2,
z: f32,
}
fn keep(tier: u8) -> Piece {
let (size, lift) = match tier {
0 => (0.66, 0.02),
1 => (0.62, 0.08),
2 => (0.64, 0.08),
_ => (0.72, 0.10),
};
Piece {
size,
at: Vec2::new(0.0, lift),
z: 0.0,
}
}
fn turrets(tier: u8) -> Vec<Piece> {
let mut towers = Vec::new();
for (from, y, z) in [(3, 0.30, -0.15), (2, -0.30, 0.2)] {
if tier >= from {
for side in [-1.0, 1.0] {
towers.push(Piece {
size: 0.36,
at: Vec2::new(side * 0.40, y),
z,
});
}
}
}
towers
}
const TOWER_TOP: f32 = 0.27;
fn build_castle(parent: &mut ChildSpawnerCommands, art: &Art, tier: u8, color: Color) {
let footprint = if tier == 0 { 0.8 } else { 1.15 };
parent.spawn((
image_sprite(
&art.shadow,
Color::srgba(1.0, 1.0, 1.0, 0.7),
Vec2::splat(TILE * footprint),
),
Transform::from_translation(layout::SUN.extend(-0.4)),
));
for part in castle_parts(art, tier, color) {
let at = (part.at * TILE).extend(part.z);
let size = part.size * TILE;
let sprite = match &part.image {
Some(image) => image_sprite(image, part.tint, size),
None => Sprite::from_color(part.tint, size),
};
let mut piece = parent.spawn((sprite, Transform::from_translation(at)));
if part.pennant {
piece.insert(Pennant);
}
}
}
pub(crate) struct CastlePart {
pub image: Option<Handle<Image>>,
pub tint: Color,
pub size: Vec2,
pub at: Vec2,
pub z: f32,
pub pennant: bool,
}
pub(crate) fn castle_parts(art: &Art, tier: u8, color: Color) -> Vec<CastlePart> {
let body = castle_body(color);
let mut parts = Vec::new();
let mut add = |image: &Handle<Image>, tint: Color, piece: &Piece| {
parts.push(CastlePart {
image: Some(image.clone()),
tint,
size: Vec2::splat(piece.size),
at: piece.at,
z: piece.z,
pennant: false,
});
};
let whole = |z| Piece {
size: 0.96,
at: Vec2::ZERO,
z,
};
if tier >= 3 {
add(
&art.moat,
Color::WHITE,
&Piece {
size: 1.22,
at: Vec2::ZERO,
z: -0.3,
},
);
}
if tier >= 1 {
add(&art.wall_back, body, &whole(-0.2));
}
let keep = keep(tier);
add(&art.castle, body, &keep);
add(
&art.castle_trim,
color.lighter(0.12),
&Piece { z: 0.05, ..keep },
);
if tier >= 1 {
add(&art.wall_front, body, &whole(0.1));
}
let towers = turrets(tier);
for tower in &towers {
add(&art.turret, body, tower);
}
for tower in &towers {
let top = tower.at + Vec2::new(0.0, tower.size * TOWER_TOP);
flag(&mut parts, color, top, 0.16, 0.6, tower.z + 0.05);
}
flag(
&mut parts,
color,
keep.at + Vec2::new(-0.06, keep.size * TOWER_TOP),
0.36,
1.3,
0.3,
);
parts.sort_by(|a, b| a.z.total_cmp(&b.z));
parts
}
fn flag(parts: &mut Vec<CastlePart>, color: Color, base: Vec2, height: f32, scale: f32, z: f32) {
parts.push(CastlePart {
image: None,
tint: WALL_COLOR,
size: Vec2::new(2.5 / TILE, height),
at: Vec2::new(base.x, base.y + height / 2.0),
z,
pennant: false,
});
let size = Vec2::new(0.20, 0.12) * scale;
parts.push(CastlePart {
image: None,
tint: color,
size,
at: Vec2::new(base.x + size.x / 2.0, base.y + height - size.y / 2.0),
z,
pennant: true,
});
}
#[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>,
) -> [i32; crate::sim::MAX_PLAYERS] {
use crate::app::sim_events::SimEvent;
let mut gained = [0i32; crate::sim::MAX_PLAYERS];
for event in events {
if let SimEvent::CrabBanked { owner, points, .. } = event
&& let Some(total) = gained.get_mut(usize::from(*owner))
{
*total = total.saturating_add(*points);
}
}
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;
}
if points > 0 {
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);
}
}
pub fn cheer_tier_ups(
fx: crate::app::effects::Fx,
mut events: MessageReader<crate::app::sim_events::SimEvent>,
sim: Res<Sim>,
mut trauma: ResMut<crate::app::effects::Trauma>,
mut castles: Query<(&CastleSprite, &mut CastleKick)>,
) {
use crate::app::effects::{Burst, Fx, burst, ring};
let Fx {
mut commands,
art,
mut rng,
settings,
} = fx;
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,
},
);
burst(
&mut commands,
&mut rng,
&Burst {
image: art.puff.clone(),
pos: at + Vec2::new(0.0, TILE * 0.25),
color: castle_body(color),
count: 12,
size: 5.0,
speed: 70.0,
gravity: 170.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::{castle_body, castle_parts, gains, hop, keep, turrets};
use crate::app::art::Art;
use crate::app::palette;
fn parts(tier: u8) -> Vec<super::CastlePart> {
castle_parts(&Art::blank(), tier, palette::player_color(0))
}
#[test]
fn each_tier_draws_more_than_the_last() {
let counts: Vec<usize> = (0..=3).map(|tier| parts(tier).len()).collect();
assert!(counts.windows(2).all(|w| w[0] < w[1]), "{counts:?}");
}
#[test]
fn only_the_top_tier_digs_a_moat() {
let moats = |tier| parts(tier).iter().filter(|p| p.size.x > 1.0).count();
assert_eq!([moats(0), moats(1), moats(2), moats(3)], [0, 0, 0, 1]);
}
#[test]
fn every_tower_flies_a_pennant() {
for tier in 0..=3 {
let pennants = parts(tier).iter().filter(|p| p.pennant).count();
assert_eq!(pennants, turrets(tier).len() + 1, "tier {tier}");
}
}
#[test]
fn a_castle_comes_back_to_front() {
for tier in 0..=3 {
let depths: Vec<f32> = parts(tier).iter().map(|p| p.z).collect();
assert!(
depths.windows(2).all(|w| w[0] <= w[1]),
"tier {tier}: {depths:?}"
);
}
}
#[test]
fn a_castle_is_dyed_sand() {
let bodies: Vec<Color> = (0..6)
.map(|seat| castle_body(palette::player_color(seat)))
.collect();
for (seat, body) in bodies.iter().enumerate() {
assert_ne!(
*body,
palette::player_color(seat as u8),
"seat {seat} is raw colour"
);
for other in &bodies[seat + 1..] {
assert_ne!(body, other, "two owners dye alike");
}
}
}
use crate::app::sim_events::SimEvent;
use crate::sim::{CrabKind, MAX_PLAYERS};
use bevy::prelude::*;
#[test]
fn every_tier_adds_to_the_castle() {
let towers: Vec<usize> = (0..=3).map(|tier| turrets(tier).len()).collect();
assert_eq!(towers, [0, 0, 2, 4]);
for tier in 1..=3 {
let before: Vec<_> = turrets(tier - 1).iter().map(|t| t.at).collect();
let now: Vec<_> = turrets(tier).iter().map(|t| t.at).collect();
assert!(
before.iter().all(|at| now.contains(at)),
"tier {tier} lost a tower"
);
}
assert!(
keep(3).size > keep(0).size,
"the top tier's keep stands taller"
);
}
fn bank(owner: u8, points: i32) -> SimEvent {
SimEvent::CrabBanked {
id: 0,
owner,
pos: Vec2::ZERO,
keep: Vec2::ZERO,
points,
kind: CrabKind::Common,
handed: crate::sim::Handedness::Right,
}
}
#[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::<i32>(), 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);
}
}