use super::{OnTile, WALL_COLOR, clear_stale, 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,
}
impl OnTile for CastleSprite {
fn tile(&self) -> (u8, u8) {
(self.x, self.y)
}
}
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;
clear_stale(
&mut commands,
board,
&mut covered,
existing,
|sprite| match board.tile_at(sprite.x, sprite.y) {
TileKind::Castle(owner) => {
owner == sprite.owner && castle_tier(board.scores()[owner as usize]) == sprite.tier
}
TileKind::Empty
| TileKind::Rock
| TileKind::Spawner(_)
| TileKind::Turnstile { .. }
| TileKind::Kelp
| TileKind::Pool => false,
},
);
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;
};
spawn_castle(&mut commands, &art, board, (x, y), owner);
}
}
pub(crate) fn spawn_castle(
commands: &mut Commands,
art: &Art,
board: &crate::sim::Board,
(x, y): (u8, u8),
owner: u8,
) -> Entity {
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),
CastleFlinch::default(),
Transform::from_translation(pos.extend(z::CASTLE)),
Visibility::default(),
))
.with_children(|parent| build_castle(parent, art, tier, color))
.id()
}
const SAND: Color = Color::srgb(0.925, 0.84, 0.66);
pub fn castle_body(owner: Color) -> Color {
SAND.mix(&owner, 0.62)
}
#[derive(Component)]
pub struct CastleShadow {
at: Vec3,
}
type SwollenCastle = (With<CastleSprite>, Changed<Transform>);
pub fn steady_castle_shadows(
castles: Query<(&Transform, &CastleFlinch, &Children), SwollenCastle>,
mut shadows: Query<(&CastleShadow, &mut Transform), Without<CastleSprite>>,
) {
for (castle, flinch, children) in &castles {
let scale = castle.scale.x.max(f32::EPSILON);
let unlean = castle.rotation.inverse();
for child in children {
if let Ok((shadow, mut place)) = shadows.get_mut(*child) {
let ground = shadow.at.truncate() - flinch.offset;
let local = (unlean * ground.extend(0.0)).truncate() / scale;
place.translation = local.extend(shadow.at.z);
place.rotation = unlean;
place.scale = swell(1.0 / scale);
}
}
}
}
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;
pub(crate) fn build_castle(parent: &mut ChildSpawnerCommands, art: &Art, tier: u8, color: Color) {
let footprint = if tier == 0 { 0.8 } else { 1.15 };
parent.spawn((
CastleShadow {
at: layout::SUN.extend(-0.4),
},
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)),
));
let (sprite, place) = super::cast_shadow(
art,
Vec3::new(0.0, 0.0, -0.41),
super::Caster {
footprint: TILE * footprint,
height: TILE * (0.14 + 0.05 * f32::from(tier)),
alpha: 0.45,
},
);
parent.spawn((
CastleShadow {
at: place.translation,
},
sprite,
place,
));
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, PartTint(part.tint), 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),
KEEP_POLE,
1.3,
0.3,
);
parts.sort_by(|a, b| a.z.total_cmp(&b.z));
parts
}
const FLAG_LIFT: f32 = z::FLAG - z::CASTLE;
const FLAG_DEPTH: f32 = 0.1;
fn flag(parts: &mut Vec<CastlePart>, color: Color, base: Vec2, height: f32, scale: f32, z: f32) {
let z = FLAG_LIFT + FLAG_DEPTH * z;
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,
});
}
fn crown_lift(tier: u8) -> f32 {
let keep = keep(tier);
keep.at.y + keep.size * TOWER_TOP + KEEP_POLE + CROWN_SIZE * 0.32
}
const KEEP_POLE: f32 = 0.36;
const CROWN_SIZE: f32 = 0.5;
#[derive(Component)]
pub struct CastleCrown {
x: u8,
y: u8,
age: f32,
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct Table<'w> {
screen: Res<'w, State<crate::app::Screen>>,
seats: Res<'w, crate::app::Seats>,
scoring: crate::app::teams::Scoring<'w>,
time: Res<'w, Time>,
clock: Res<'w, crate::app::round_state::RoundClock>,
}
pub fn crown_the_leader(
mut commands: Commands,
sim: Res<Sim>,
art: Res<Art>,
table: Table,
castles: Query<(&CastleSprite, &Transform), Without<CastleCrown>>,
mut crowns: Query<(Entity, &mut CastleCrown, &mut Transform), Without<CastleSprite>>,
) {
let board = &sim.0;
let versus = *table.screen.get() == crate::app::Screen::Versus;
let leaders = table.scoring.leaders(board.scores(), table.seats.0);
let leading = |c: &CastleSprite| versus && leaders[usize::from(c.owner)];
for (entity, crown, _) in &crowns {
let kept = castles
.iter()
.any(|(c, _)| c.x == crown.x && c.y == crown.y && leading(c));
if !kept {
commands.entity(entity).despawn();
}
}
let calm = table.scoring.local.settings.reduced_motion;
let dt = table.time.delta_secs();
let now = table.clock.0;
for (castle, place) in &castles {
if !leading(castle) {
continue;
}
let over = place.translation.truncate()
+ Vec2::new(0.0, crown_lift(castle.tier) * TILE * place.scale.y);
let found = crowns
.iter_mut()
.find(|(_, crown, _)| crown.x == castle.x && crown.y == castle.y);
let Some((_, mut crown, mut at)) = found else {
commands.spawn((
super::BoardStatic,
CastleCrown {
x: castle.x,
y: castle.y,
age: 0.0,
},
image_sprite(&art.crown, Color::WHITE, Vec2::splat(CROWN_SIZE * TILE)),
Transform::from_translation(over.extend(z::CROWN))
.with_scale(Vec3::splat(if calm { 1.0 } else { 0.0 })),
));
continue;
};
crown.age += dt;
let (drop, grow) = if calm { (0.0, 1.0) } else { arrival(crown.age) };
let bob = if calm { 0.0 } else { (now * 2.2).sin() * 1.5 };
at.translation = (over + Vec2::new(0.0, drop * TILE + bob)).extend(z::CROWN);
at.scale = Vec3::splat(grow);
}
}
fn arrival(age: f32) -> (f32, f32) {
const DROP: f32 = 0.35;
let t = (age / DROP).clamp(0.0, 1.0);
let back = 1.0 + 2.7 * (t - 1.0).powi(3) + 1.7 * (t - 1.0).powi(2);
((1.0 - t) * 0.4, back)
}
#[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 {
board: crate::app::sim_events::BoardStamp,
held: Vec<Held>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct Held {
x: u8,
y: u8,
owner: u8,
}
fn swell(scale: f32) -> Vec3 {
Vec3::new(scale, scale, 1.0)
}
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>,
current: crate::app::sim_events::CurrentBoard,
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 = ¤t.sim.0;
if before.board.is_new(¤t) {
before.held.clear();
flight.from.clear();
flight.left = 0.0;
}
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 = swell(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>,
) -> Vec<(Vec2, i32)> {
use crate::app::sim_events::SimEvent;
let mut gained: Vec<(Vec2, i32)> = Vec::new();
for event in events {
let SimEvent::CrabBanked { keep, points, .. } = event else {
continue;
};
match gained.iter_mut().find(|(at, _)| same_keep(*at, *keep)) {
Some((_, total)) => *total = total.saturating_add(*points),
None => gained.push((*keep, *points)),
}
}
gained
}
fn same_keep(a: Vec2, b: Vec2) -> bool {
a.distance_squared(b) < (TILE * 0.25).powi(2)
}
fn gained_at(gained: &[(Vec2, i32)], at: Vec2) -> i32 {
gained
.iter()
.find(|(keep, _)| same_keep(*keep, at))
.map_or(0, |(_, points)| *points)
}
pub(crate) fn kick_keep(
board: &crate::sim::Board,
castles: &mut Query<(&CastleSprite, &mut CastleKick)>,
keep: Vec2,
) {
for (sprite, mut kick) in castles.iter_mut() {
if on_board(board, sprite.x, sprite.y)
&& same_keep(layout::tile_center(board, sprite.x, sprite.y), keep)
{
kick.0 = 1.0;
}
}
}
pub fn kick_castles(
mut commands: Commands,
mut events: MessageReader<crate::app::sim_events::SimEvent>,
sim: Res<Sim>,
time: Res<Time>,
round: crate::app::round_state::RoundState,
mut castles: Query<(&CastleSprite, &mut CastleKick, &mut Transform)>,
) {
let board = &sim.0;
let gained = gains(events.read());
for (sprite, _, _) in &castles {
if !on_board(board, sprite.x, sprite.y) {
continue;
}
let TileKind::Castle(owner) = board.tile_at(sprite.x, sprite.y) else {
continue;
};
let at = layout::tile_center(board, sprite.x, sprite.y);
let points = gained_at(&gained, at);
if points == 0 {
continue;
}
crate::app::effects::score_pip(
&mut commands,
format!("{points:+}"),
at + Vec2::new(0.0, TILE * 0.35),
palette::player_color(owner).lighter(0.15),
);
}
if round.held() {
return;
}
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 = swell(wobble);
}
}
#[derive(Component)]
pub struct PartTint(Color);
#[derive(Component, Default)]
pub struct CastleFlinch {
since: Option<f32>,
lean: f32,
offset: Vec2,
}
const FLINCH: f32 = 1.6;
const SWAY_LEAN: f32 = 7.0 * std::f32::consts::PI / 180.0;
const SWAY_DECAY: f32 = 3.4;
const SWAY_HZ: f32 = 2.3;
const FLINCH_SHADE: f32 = 0.34;
const FOOT: f32 = 0.25;
const CRUMB_WAVES: [(f32, usize); 4] = [(0.04, 5), (0.30, 3), (0.48, 2), (0.66, 2)];
fn sway(since: f32) -> f32 {
SWAY_LEAN * (-SWAY_DECAY * since).exp() * (std::f32::consts::TAU * SWAY_HZ * since).sin()
}
fn flinch_shade(since: f32) -> f32 {
use crate::app::effects::smoothstep;
FLINCH_SHADE * (since / 0.06).min(1.0) * (1.0 - smoothstep((since - 0.1) / 0.85))
}
fn planted(lean: f32, scale: f32, swaying: bool) -> Vec2 {
if !swaying {
return Vec2::ZERO;
}
let foot = Vec2::new(0.0, -FOOT * TILE);
foot - (Quat::from_rotation_z(lean) * (foot * scale).extend(0.0)).truncate()
}
fn shaded(tint: Color, shade: f32) -> Color {
let c = tint.to_srgba();
let keep = 1.0 - shade;
Color::srgba(c.red * keep, c.green * keep, c.blue * keep, c.alpha)
}
#[allow(clippy::too_many_arguments)]
pub fn flinch_castles(
fx: crate::app::effects::Fx,
mut events: MessageReader<crate::app::sim_events::SimEvent>,
sim: Res<Sim>,
time: Res<Time>,
round: crate::app::round_state::RoundState,
flight: Res<CastleFlight>,
mut castles: Query<(&CastleSprite, &mut CastleFlinch, &mut Transform, &Children)>,
mut parts: Query<(&PartTint, &mut Sprite)>,
) {
use crate::app::sim_events::SimEvent;
let crate::app::effects::Fx {
mut commands,
art,
mut rng,
settings,
} = fx;
let board = &sim.0;
let calm = settings.reduced_motion;
for event in events.read() {
let SimEvent::CastleRaided { pos, .. } = event else {
continue;
};
for (sprite, mut flinch, _, _) in &mut castles {
if on_board(board, sprite.x, sprite.y)
&& same_keep(layout::tile_center(board, sprite.x, sprite.y), *pos)
{
flinch.since = Some(0.0);
}
}
}
if round.held() {
return;
}
let dt = time.delta_secs();
for (sprite, mut flinch, mut transform, children) in &mut castles {
let Some(before) = flinch.since else {
continue;
};
let since = before + dt;
let done = since >= FLINCH;
flinch.since = (!done).then_some(since);
let shade = if done { 0.0 } else { flinch_shade(since) };
for child in children {
if let Ok((tint, mut piece)) = parts.get_mut(*child) {
piece.color = shaded(tint.0, shade);
}
}
let still = calm || done || flight.left > 0.0 || !on_board(board, sprite.x, sprite.y);
flinch.lean = if still { 0.0 } else { sway(since) };
let lean = Quat::from_rotation_z(flinch.lean);
flinch.offset = planted(flinch.lean, transform.scale.x, !(done || still));
if flight.left <= 0.0 && on_board(board, sprite.x, sprite.y) {
let home = layout::tile_center(board, sprite.x, sprite.y);
transform.translation = (home + flinch.offset).extend(transform.translation.z);
transform.rotation = lean;
}
if calm {
continue;
}
let keep = keep(sprite.tier);
let center = layout::tile_center(board, sprite.x, sprite.y);
for (at, count) in CRUMB_WAVES {
if before < at && at <= since {
crate::app::effects::crumbs(
&mut commands,
&mut rng,
&art,
&crate::app::effects::Crumbs {
rim: center
+ Vec2::new(
-flinch.lean.sin() * TILE * 0.33,
(keep.at.y + keep.size * 0.30) * TILE,
),
foot: center.y - FOOT * TILE,
body: castle_body(palette::player_color(sprite.owner)),
count,
life: 1.5 - at,
},
);
}
}
}
}
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;
let events: Vec<&SimEvent> = events.read().collect();
let landing: Vec<Vec2> = events
.iter()
.filter_map(|event| {
let SimEvent::CrabBanked { keep, points, .. } = event else {
return None;
};
(*points > 0).then_some(*keep)
})
.collect();
for event in &events {
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));
}
if !landing.iter().any(|keep| same_keep(*keep, at)) {
kick.0 = 1.0;
}
}
trauma.add(0.2);
}
}
pub fn wave_pennants(
time: Res<Time>,
settings: Res<crate::app::settings::GameSettings>,
castles: Query<&CastleFlinch>,
mut flags: Query<(Entity, &mut Transform, &ChildOf), With<Pennant>>,
) {
let t = time.elapsed_secs();
for (entity, mut transform, castle) in &mut flags {
let fling = castles
.get(castle.parent())
.map_or(0.0, |flinch| -2.0 * flinch.lean);
let (stretch, tilt) = if settings.reduced_motion {
(1.0, 0.0)
} else {
let phase = f32::from(entity.to_bits() as u16 % 17);
(
1.0 + (t * 5.0 + phase).sin() * 0.12,
(t * 3.0 + phase).sin() * 0.08,
)
};
transform.scale.x = stretch;
transform.rotation = Quat::from_rotation_z(tilt + fling);
}
}
#[cfg(test)]
mod tests {
use super::{
FLINCH, FLINCH_SHADE, SWAY_LEAN, castle_body, castle_parts, flinch_shade, gained_at, gains,
hop, keep, sway, turrets,
};
#[test]
fn a_raided_castle_darkens_and_sways_back_to_rest() {
assert_eq!(sway(0.0), 0.0, "upright at the hit");
assert_eq!(flinch_shade(0.0), 0.0);
assert!(
(flinch_shade(0.08) - FLINCH_SHADE).abs() < 1e-6,
"dark at once"
);
assert_eq!(flinch_shade(1.0), 0.0, "and back within the second");
let mut swung = (0.0f32, 0.0f32);
for frame in 0..100 {
let since = frame as f32 / 60.0;
let lean = sway(since);
assert!(lean.abs() <= SWAY_LEAN, "{lean} at {since}");
swung = (swung.0.min(lean), swung.1.max(lean));
}
assert!(
swung.0 < -0.02 && swung.1 > 0.02,
"it swings both ways: {swung:?}"
);
assert!(sway(FLINCH).abs() < 0.01, "and has all but settled");
}
#[test]
fn the_crown_lands_on_its_place_above_the_flag() {
use super::{CROWN_SIZE, KEEP_POLE, TOWER_TOP, arrival, crown_lift};
let (drop, grow) = arrival(0.0);
assert!(
drop > 0.0 && grow < 0.05,
"arrives from above, small: {drop} {grow}"
);
assert_eq!(
arrival(1.0),
(0.0, 1.0),
"and rests at full size on its place"
);
for tier in 0..=3 {
let keep = keep(tier);
let pole_top = keep.at.y + keep.size * TOWER_TOP + KEEP_POLE;
let crown_foot = crown_lift(tier) - CROWN_SIZE * 0.5 * 0.7;
assert!(
crown_foot > pole_top - 0.02,
"tier {tier}: crown on the flag"
);
}
}
#[test]
fn pennants_hang_still_for_less_motion() {
use super::{Pennant, wave_pennants};
use bevy::prelude::*;
let mut app = App::new();
app.init_resource::<Time>();
app.insert_resource(crate::app::settings::GameSettings {
reduced_motion: true,
..Default::default()
});
app.add_systems(Update, wave_pennants);
let flags: Vec<Entity> = (0..5)
.map(|_| app.world_mut().spawn((Pennant, Transform::default())).id())
.collect();
app.update();
for flag in flags {
let at = app.world().get::<Transform>(flag).expect("a flag");
assert_eq!(*at, Transform::default(), "{flag:?} waved");
}
}
#[test]
fn a_swelling_or_swaying_castle_leaves_its_shadow_on_the_ground() {
use super::{CastleFlinch, CastleShadow, CastleSprite, steady_castle_shadows};
use bevy::prelude::*;
let at = Vec3::new(15.0, -21.0, -0.41);
for lean in [0.0f32, 0.1, -0.12] {
let mut app = App::new();
let scale = 1.25;
let turn = Quat::from_rotation_z(lean);
let offset = Vec2::new(4.0, -1.5);
let castle = app
.world_mut()
.spawn((
CastleSprite {
x: 0,
y: 0,
tier: 3,
owner: 0,
},
CastleFlinch {
since: Some(0.2),
lean,
offset,
},
Transform::from_translation(offset.extend(0.0))
.with_rotation(turn)
.with_scale(Vec3::splat(scale)),
))
.id();
let shadow = app
.world_mut()
.spawn((
CastleShadow { at },
Transform::from_translation(at),
ChildOf(castle),
))
.id();
app.add_systems(Update, steady_castle_shadows);
app.update();
let place = *app.world().get::<Transform>(shadow).expect("the shadow");
let drawn =
offset + (turn * (place.translation.truncate() * scale).extend(0.0)).truncate();
assert!(
(drawn - at.truncate()).length() < 1e-3,
"lean {lean}: it lunged to {drawn}"
);
assert!(
(place.scale.x * scale - 1.0).abs() < 1e-4,
"it grew to {}",
place.scale.x
);
assert!(
(turn * place.rotation).angle_between(Quat::IDENTITY) < 1e-4,
"lean {lean}: it turned with the castle"
);
}
}
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 the_flags_fly_over_the_fences() {
use crate::app::layout::z;
for tier in 0..=3 {
let parts = parts(tier);
let flags: Vec<&super::CastlePart> =
parts.iter().filter(|p| p.image.is_none()).collect();
assert_eq!(flags.len(), 2 * (turrets(tier).len() + 1), "tier {tier}");
for flag in flags {
let drawn = z::CASTLE + flag.z;
assert!(
drawn > z::WALL + 0.1 && drawn < z::CROWN,
"tier {tier}: a flag at {drawn}"
);
}
for piece in parts.iter().filter(|p| p.image.is_some()) {
assert!(
z::CASTLE + piece.z < z::SIGNPOST,
"tier {tier}: a piece at {}",
z::CASTLE + piece.z
);
}
}
}
#[test]
fn a_swollen_castle_keeps_its_flags_on_their_layer() {
use super::{Pennant, RISE, build_castle, swell};
use crate::app::layout::z;
use bevy::ecs::system::RunSystemOnce;
for scale in [1.0, 1.0 + RISE, 1.2, 1.0 / (1.0 + RISE)] {
let mut app = App::new();
app.add_plugins(TransformPlugin);
app.world_mut()
.run_system_once(move |mut commands: Commands| {
commands
.spawn(
Transform::from_translation(Vec3::new(0.0, 0.0, z::CASTLE))
.with_scale(swell(scale)),
)
.with_children(|castle| {
build_castle(castle, &Art::blank(), 3, palette::player_color(0));
});
})
.expect("a castle");
app.update();
let world = app.world_mut();
let mut pennants = world.query_filtered::<&GlobalTransform, With<Pennant>>();
let depths: Vec<f32> = pennants.iter(world).map(|at| at.translation().z).collect();
assert_eq!(depths.len(), turrets(3).len() + 1);
for drawn in depths {
assert!(
drawn > z::WALL + 0.1 && drawn < z::CROWN,
"at {scale}x a flag at {drawn}"
);
}
}
let (_, top) = hop(Vec2::ZERO, Vec2::X, 0.5);
assert!((top - (1.0 + RISE)).abs() < 1e-5);
}
#[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;
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, keep: Vec2, points: i32) -> SimEvent {
SimEvent::CrabBanked {
id: 0,
owner,
pos: Vec2::ZERO,
keep,
points,
kind: CrabKind::Common,
handed: crate::sim::Handedness::Right,
}
}
#[test]
fn a_frame_of_banks_floats_one_number_and_it_is_the_total() {
let keep = Vec2::new(64.0, -32.0);
assert!(gains([]).is_empty(), "a quiet frame gains nobody");
assert_eq!(gained_at(&gains(&[bank(1, keep, 3)]), keep), 3);
let stream: Vec<SimEvent> = (0..8).map(|_| bank(1, keep, 1)).collect();
assert_eq!(
gained_at(&gains(&stream), keep),
8,
"eight into one keep is eight, not eight ones"
);
let golden = gains(&[bank(2, keep, 50), bank(2, keep, 1)]);
assert_eq!(gained_at(&golden, keep), 51);
}
#[test]
fn every_keep_is_counted_on_its_own_even_two_of_one_seat() {
let (left, right, far) = (
Vec2::new(-200.0, 0.0),
Vec2::new(200.0, 0.0),
Vec2::new(0.0, 150.0),
);
let frame = vec![
bank(0, left, 1),
bank(0, right, 2),
bank(0, left, 4),
bank(3, far, 1),
];
let gained = gains(&frame);
assert_eq!(gained_at(&gained, left), 5, "two crabs home on the left");
assert_eq!(gained_at(&gained, right), 2, "and one on the right");
assert_eq!(gained_at(&gained, far), 1, "a rival's keep is its own");
assert_eq!(
gained_at(&gained, Vec2::new(0.0, -150.0)),
0,
"and a keep that banked nothing floats nothing"
);
let total: i32 = gained.iter().map(|(_, points)| points).sum();
assert_eq!(total, 8, "nothing counted twice");
}
#[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_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);
}
fn castle_stage() -> (App, Vec2) {
let mut board = crate::sim::Board::new(5, 5, 1);
board.set_tile(1, 1, crate::sim::TileKind::Castle(0));
let keep = crate::app::layout::tile_center(&board, 1, 1);
let mut app = App::new();
app.insert_resource(crate::app::Sim(board));
app.insert_resource(Art::blank());
app.init_resource::<crate::app::effects::VisualRng>();
app.init_resource::<crate::app::effects::Trauma>();
app.init_resource::<crate::app::settings::GameSettings>();
app.init_resource::<Time>();
app.add_message::<SimEvent>();
(app, keep)
}
fn castle_at_one_one() -> super::CastleSprite {
super::CastleSprite {
x: 1,
y: 1,
tier: 1,
owner: 0,
}
}
#[test]
fn a_tier_up_bounces_the_castle_when_its_crab_lands() {
use super::{CastleKick, cheer_tier_ups};
for crab in [true, false] {
let (mut app, keep) = castle_stage();
app.add_systems(Update, cheer_tier_ups);
let castle = app
.world_mut()
.spawn((castle_at_one_one(), CastleKick(0.0)))
.id();
if crab {
app.world_mut().write_message(bank(0, keep, 1));
}
app.world_mut().write_message(SimEvent::TierUp { owner: 0 });
app.update();
let kick = app.world().get::<CastleKick>(castle).expect("a castle").0;
match crab {
true => assert_eq!(kick, 0.0, "it waits for the crab to land"),
false => assert_eq!(kick, 1.0, "nothing to wait for"),
}
}
}
#[test]
fn a_flying_castle_is_not_moved_to_plant_its_foot() {
use super::{CastleFlight, CastleFlinch, flinch_castles};
use crate::app::{Screen, VersusPhase};
let (mut app, _) = castle_stage();
app.insert_resource(CastleFlight {
left: 0.5,
from: Vec::new(),
});
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, flinch_castles);
let castle = app
.world_mut()
.spawn((
castle_at_one_one(),
CastleFlinch {
since: Some(0.2),
lean: 0.0,
offset: Vec2::ZERO,
},
Transform::from_scale(Vec3::splat(1.3)),
))
.id();
app.world_mut()
.spawn((Transform::default(), ChildOf(castle)));
app.update();
let flinch = app.world().get::<CastleFlinch>(castle).expect("a castle");
assert!(flinch.since.is_some(), "still flinching");
assert_eq!(flinch.offset, Vec2::ZERO);
}
}