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 = sprite.y as usize * board.width() as usize + sprite.x as usize;
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 = y as usize * board.width() as usize + x as usize;
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| {
if tier >= 1 {
parent.spawn((
Sprite::from_color(color.darker(0.12), Vec2::splat(TILE * 0.95)),
Transform::from_translation(Vec3::new(0.0, 0.0, -0.1)),
));
}
if tier >= 2 {
for side in [-1.0, 1.0] {
parent.spawn((
Sprite::from_color(color.lighter(0.08), Vec2::splat(TILE * 0.22)),
Transform::from_translation(Vec3::new(
side * base_size * 0.42,
base_size * 0.42,
0.1,
)),
));
}
}
if tier >= 3 {
parent.spawn((
Sprite::from_color(Color::srgb(0.35, 0.62, 0.85), Vec2::splat(TILE * 1.05)),
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 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();
let now: Vec<Held> = 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,
})
.collect();
if swapped(&before.held, &now) && !settings.reduced_motion {
flight.from.clear();
let mut old = before.held.clone();
for held in &now {
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;
}
before.held = 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)
}
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)>,
) {
use crate::app::sim_events::SimEvent;
let board = &sim.0;
let calm = settings.reduced_motion;
for event in events.read() {
let SimEvent::CrabBanked { owner, value, .. } = event else {
continue;
};
for (sprite, mut kick, _) in &mut castles {
if on_board(board, sprite.x, sprite.y)
&& board.tile_at(sprite.x, sprite.y) == TileKind::Castle(*owner)
{
kick.0 = if calm { 0.0 } else { 1.0 };
crate::app::effects::score_pip(
&mut commands,
format!("+{value}"),
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 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::hop;
use bevy::prelude::*;
#[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);
}
}