use super::{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::{Board, Direction, SignpostHealth, TileKind};
use bevy::prelude::*;
#[derive(Component)]
pub struct SignpostSprite {
x: u8,
y: u8,
dir: Direction,
owner: u8,
age: f32,
planted: u64,
}
#[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 SIGN_SIZE: f32 = TILE * 0.94;
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
}
#[derive(Component)]
pub struct BoardStatic;
fn spawn_sand(commands: &mut Commands, art: &Art, pos: Vec2, x: u8, y: u8) {
let sand = if (x + y).is_multiple_of(2) {
&art.sand_a
} else {
&art.sand_b
};
let quarter_turns = f32::from((x.wrapping_mul(7).wrapping_add(y.wrapping_mul(13))) % 4);
commands.spawn((
BoardStatic,
image_sprite(sand, Color::WHITE, Vec2::splat(TILE)),
Transform::from_translation(pos.extend(z::SAND)).with_rotation(Quat::from_rotation_z(
quarter_turns * std::f32::consts::FRAC_PI_2,
)),
));
}
fn ground_shadow(commands: &mut Commands, art: &Art, pos: Vec2, size: f32) {
commands.spawn((
BoardStatic,
image_sprite(
&art.shadow,
Color::srgba(1.0, 1.0, 1.0, 0.75),
Vec2::new(size, size * 0.82),
),
Transform::from_translation((pos + layout::SUN).extend(z::TILE_FEATURE - 0.05)),
));
}
fn spawn_tile_feature(
commands: &mut Commands,
art: &Art,
(x, y): (u8, u8),
pos: Vec2,
kind: TileKind,
) {
match kind {
TileKind::Empty => {}
TileKind::Rock => {
ground_shadow(commands, art, pos, TILE * 0.98);
commands.spawn((
BoardStatic,
image_sprite(&art.rock, Color::WHITE, Vec2::splat(TILE * 0.94)),
Transform::from_translation(pos.extend(z::TILE_FEATURE)),
));
}
TileKind::Castle(_) => {}
TileKind::Spawner(spawner) => {
commands.spawn((
BoardStatic,
SpawnerSprite {
period: spawner.period,
at: (x, y),
},
image_sprite(&art.hole, Color::WHITE, Vec2::splat(TILE * 0.78)),
Transform::from_translation(pos.extend(z::TILE_FEATURE)),
));
}
TileKind::Kelp => {
ground_shadow(
commands,
art,
pos + Vec2::new(0.0, -TILE * 0.22),
TILE * 0.7,
);
commands.spawn((
BoardStatic,
image_sprite(&art.kelp, Color::WHITE, Vec2::splat(TILE * 0.94)),
Transform::from_translation(pos.extend(z::TILE_FEATURE)),
));
}
TileKind::Pool => {}
TileKind::Turnstile { .. } => {
commands.spawn((
BoardStatic,
image_sprite(&art.shadow, Color::WHITE, Vec2::splat(TILE * 0.8)),
Transform::from_translation((pos + layout::SUN).extend(z::POOL)),
));
}
}
}
pub fn spawn_static_board(commands: &mut Commands, board: &Board, art: &Art) {
spawn_dusk_shore(commands, board, art);
for (x, y, kind) in board.tiles() {
let pos = layout::tile_center(board, x, y);
spawn_sand(commands, art, pos, x, y);
spawn_tile_feature(commands, art, (x, y), pos, kind);
}
spawn_pools(commands, board, art);
spawn_walls(commands, board, art);
spawn_weather(commands, board, art);
}
const POOL_LAYERS: [(Color, f32, Vec2); 3] = [
(Color::srgb(0.77, 0.66, 0.48), 1.1, Vec2::new(1.2, 1.0)),
(Color::srgb(0.44, 0.69, 0.80), 0.98, Vec2::new(1.1, 0.86)),
(Color::srgb(0.33, 0.57, 0.72), 0.62, Vec2::new(1.0, 0.58)),
];
#[derive(Component)]
pub struct PoolRipple {
phase: f32,
}
const RIPPLE_PERIOD: f32 = 4.2;
fn tile_hash(x: u8, y: u8) -> u32 {
let h = u32::from(x).wrapping_mul(0x9E37_79B1) ^ u32::from(y).wrapping_mul(0x85EB_CA77);
h ^ (h >> 15)
}
fn spawn_pools(commands: &mut Commands, board: &Board, art: &Art) {
let pieces = pond_pieces(board);
for (layer, (tint, size, bridge)) in POOL_LAYERS.into_iter().enumerate() {
let z = z::POOL - 0.03 + 0.01 * layer as f32;
for piece in &pieces {
let (at, turn, extent) = match *piece {
PondPiece::Tile { at, turn } | PondPiece::Corner { at, turn } => {
(at, turn, Vec2::splat(size))
}
PondPiece::Bridge { at, turn } => (at, turn, bridge),
};
commands.spawn((
BoardStatic,
image_sprite(&art.puddle, tint, extent * TILE),
Transform::from_translation(at.extend(z))
.with_rotation(Quat::from_rotation_z(turn)),
));
}
}
for (x, y, kind) in board.tiles() {
if kind != TileKind::Pool {
continue;
}
let hash = tile_hash(x, y);
let wander = Vec2::new(
((hash >> 8) % 100) as f32 / 100.0 - 0.5,
((hash >> 16) % 100) as f32 / 100.0 - 0.5,
) * TILE
* 0.12;
commands.spawn((
BoardStatic,
PoolRipple {
phase: ((hash >> 4) % 1000) as f32 / 1000.0,
},
image_sprite(&art.ripple, Color::NONE, Vec2::splat(TILE)),
Transform::from_translation(
(layout::tile_center(board, x, y) + wander).extend(z::POOL + 0.005),
)
.with_rotation(Quat::from_rotation_z(pool_turn(hash))),
));
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum PondPiece {
Tile { at: Vec2, turn: f32 },
Bridge { at: Vec2, turn: f32 },
Corner { at: Vec2, turn: f32 },
}
fn pool_turn(hash: u32) -> f32 {
(hash % 628) as f32 / 100.0
}
fn pond_pieces(board: &Board) -> Vec<PondPiece> {
let pool = |x: u8, y: u8| {
x < board.width() && y < board.height() && board.tile_at(x, y) == TileKind::Pool
};
let mut pieces = Vec::new();
for (x, y, kind) in board.tiles() {
if kind != TileKind::Pool {
continue;
}
let at = layout::tile_center(board, x, y);
let turn = pool_turn(tile_hash(x, y));
pieces.push(PondPiece::Tile { at, turn });
for (dx, dy, turn) in [(1, 0, 0.0), (0, 1, std::f32::consts::FRAC_PI_2)] {
if pool(x + dx, y + dy) {
let next = layout::tile_center(board, x + dx, y + dy);
pieces.push(PondPiece::Bridge {
at: (at + next) / 2.0,
turn,
});
}
}
if pool(x + 1, y) && pool(x, y + 1) && pool(x + 1, y + 1) {
pieces.push(PondPiece::Corner {
at: at + Vec2::new(TILE / 2.0, -TILE / 2.0),
turn: turn + 1.0,
});
}
}
pieces
}
pub fn ripple_pools(
time: Res<Time>,
mut ripples: Query<(&PoolRipple, &mut Transform, &mut Sprite)>,
) {
let now = time.elapsed_secs();
for (ripple, mut transform, mut sprite) in &mut ripples {
let t = (now / RIPPLE_PERIOD + ripple.phase).fract();
transform.scale = Vec3::splat(0.12 + 0.5 * t);
let strength = (t * std::f32::consts::PI).sin();
sprite.color = Color::srgba(0.88, 0.95, 0.98, 0.42 * strength);
}
}
const CLOUD_REACH: f32 = 2400.0;
#[derive(Component)]
pub struct CloudShadow {
speed: f32,
edge: f32,
}
fn spawn_weather(commands: &mut Commands, board: &Board, art: &Art) {
let w = f32::from(board.width()) * TILE;
let h = f32::from(board.height()) * TILE;
commands.spawn((
BoardStatic,
image_sprite(&art.vignette, Color::WHITE, Vec2::new(w + 40.0, h + 40.0)),
Transform::from_translation(Vec3::new(0.0, 0.0, z::POOL - 0.05)),
));
let edge = w / 2.0 + CLOUD_REACH;
for (i, (span, speed, at)) in [(3.4, 9.0, -0.28), (5.0, -6.0, 0.12), (2.6, 13.0, 0.38)]
.into_iter()
.enumerate()
{
commands.spawn((
BoardStatic,
CloudShadow { speed, edge },
image_sprite(
&art.cloud,
Color::srgba(0.10, 0.13, 0.20, 0.10),
Vec2::new(span * TILE, span * TILE * 0.62),
),
Transform::from_translation(Vec3::new(
-w / 2.0 + (i as f32 + 0.5) / 3.0 * w,
at * h,
z::POOL - 0.08,
)),
));
}
}
pub fn drift_cloud_shadows(
time: Res<Time>,
settings: Res<crate::app::settings::GameSettings>,
mut clouds: Query<(&CloudShadow, &mut Transform)>,
) {
if settings.reduced_motion {
return;
}
let dt = time.delta_secs();
for (cloud, mut transform) in &mut clouds {
transform.translation.x += cloud.speed * dt;
if transform.translation.x > cloud.edge {
transform.translation.x = -cloud.edge;
} else if transform.translation.x < -cloud.edge {
transform.translation.x = cloud.edge;
}
}
}
const SURROUND_SAND: Color = Color::srgb(0.70, 0.61, 0.45);
fn spawn_dusk_shore(commands: &mut Commands, board: &Board, art: &Art) {
const REACH: f32 = super::wash::REACH;
let top = f32::from(board.height()) * TILE / 2.0 + 26.0;
let plane = |size: Vec2, at: Vec2, z: f32, color: Color| {
(
BoardStatic,
Sprite::from_color(color, size),
Transform::from_translation(at.extend(z)),
)
};
commands.spawn(plane(
Vec2::splat(REACH),
Vec2::ZERO,
z::SAND - 10.0,
SURROUND_SAND,
));
let shore = top + TILE * 0.55;
commands.spawn(plane(
Vec2::new(REACH, REACH / 2.0),
Vec2::new(0.0, shore + REACH / 4.0),
z::SAND - 9.8,
Color::srgb(0.13, 0.30, 0.42),
));
commands.spawn(plane(
Vec2::new(REACH, 26.0),
Vec2::new(0.0, shore + 13.0),
z::SAND - 9.7,
Color::srgb(0.20, 0.42, 0.55),
));
commands.spawn(plane(
Vec2::new(REACH, 18.0),
Vec2::new(0.0, shore - 9.0),
z::SAND - 9.7,
Color::srgb(0.58, 0.50, 0.37),
));
commands.spawn((
BoardStatic,
Sprite {
image: art.foam.clone(),
color: Color::srgba(1.0, 1.0, 1.0, 0.45),
custom_size: Some(Vec2::new(REACH, 12.0)),
..default()
},
Transform::from_translation(Vec3::new(0.0, shore + 2.0, z::SAND - 9.6)),
));
let mut rng = crate::app::effects::VisualRng::seeded(board.seed() as u32 ^ 0x5EA);
let span = f32::from(board.width()) * TILE / 2.0 + MARGIN_TILES * TILE;
for (lift, alpha, depth) in [(30.0, 0.34, 9.0), (62.0, 0.24, 7.0), (104.0, 0.15, 5.0)] {
let mut x = -span;
while x < span {
let length = rng.range(70.0, 190.0);
commands.spawn((
BoardStatic,
Sprite {
image: art.foam.clone(),
color: Color::srgba(1.0, 1.0, 1.0, alpha),
custom_size: Some(Vec2::new(length, depth)),
..default()
},
Transform::from_translation(Vec3::new(
x + length / 2.0,
shore + lift + rng.range(-5.0, 5.0),
z::SAND - 9.65,
)),
));
x += length + rng.range(30.0, 110.0);
}
}
furnish_the_margin(commands, board, art, shore);
}
const MARGIN_TILES: f32 = 14.0;
fn margin_spot(rng: &mut crate::app::effects::VisualRng, size: Vec2, shore: f32) -> Option<Vec2> {
let clear = size / 2.0 + Vec2::splat(super::RIM + 14.0);
let reach = size / 2.0 + Vec2::splat(MARGIN_TILES * TILE);
let at = Vec2::new(
rng.range(-reach.x, reach.x),
rng.range(-reach.y, shore - 16.0),
);
(at.x.abs() > clear.x || at.y.abs() > clear.y).then_some(at)
}
fn margin_seed(board: &Board) -> u32 {
(board.seed() as u32)
^ u32::from(board.width()).wrapping_mul(0x9E37_79B1)
^ u32::from(board.height()).wrapping_mul(0x85EB_CA77)
}
fn furnish_the_margin(commands: &mut Commands, board: &Board, art: &Art, shore: f32) {
let (w, h) = (
f32::from(board.width()) * TILE,
f32::from(board.height()) * TILE,
);
let mut rng = crate::app::effects::VisualRng::seeded(margin_seed(board));
let spot = |rng: &mut crate::app::effects::VisualRng| margin_spot(rng, Vec2::new(w, h), shore);
let z = z::SAND - 9.5;
for _ in 0..900 {
let Some(at) = spot(&mut rng) else { continue };
let pale = rng.next().is_multiple_of(3);
let tint = if pale {
Color::srgba(1.0, 0.96, 0.86, 0.22)
} else {
Color::srgba(0.45, 0.37, 0.25, 0.20)
};
commands.spawn((
BoardStatic,
Sprite::from_color(tint, Vec2::splat(rng.range(2.0, 5.5))),
Transform::from_translation(at.extend(z)),
));
}
for _ in 0..140 {
let Some(at) = spot(&mut rng) else { continue };
let (image, tint, size, turn) = match rng.next() % 10 {
0..=3 => (
&art.kelp,
Color::srgba(0.62, 0.70, 0.45, 0.80),
Vec2::splat(TILE * rng.range(0.38, 0.62)),
rng.range(-0.25, 0.25),
),
4..=5 => (
&art.log,
Color::srgba(0.92, 0.86, 0.78, 0.90),
Vec2::splat(TILE * rng.range(0.55, 0.85)),
rng.range(0.0, std::f32::consts::TAU),
),
6..=8 => (
&art.shadow,
Color::srgba(0.42, 0.40, 0.37, 0.55),
Vec2::new(TILE * 0.16, TILE * 0.11) * rng.range(0.7, 1.4),
rng.range(0.0, std::f32::consts::TAU),
),
_ => (
&art.star,
Color::srgba(0.93, 0.56, 0.42, 0.85),
Vec2::splat(TILE * rng.range(0.20, 0.28)),
rng.range(0.0, std::f32::consts::TAU),
),
};
commands.spawn((
BoardStatic,
image_sprite(&art.shadow, Color::srgba(1.0, 1.0, 1.0, 0.35), size * 0.9),
Transform::from_translation((at + layout::SUN).extend(z + 0.01)),
));
commands.spawn((
BoardStatic,
image_sprite(image, tint, size),
Transform::from_translation(at.extend(z + 0.02))
.with_rotation(Quat::from_rotation_z(turn)),
));
}
}
fn spawn_walls(commands: &mut Commands, board: &Board, art: &Art) {
let plank_size = Vec2::new(TILE + 8.0, 13.0);
let plank = |commands: &mut Commands, center: Vec2, vertical: bool| {
let rotation = if vertical {
Quat::from_rotation_z(std::f32::consts::FRAC_PI_2)
} else {
Quat::IDENTITY
};
commands.spawn((
BoardStatic,
image_sprite(&art.plank, Color::srgba(0.10, 0.07, 0.05, 0.34), plank_size),
Transform::from_translation((center + layout::SUN).extend(z::TILE_FEATURE - 0.06))
.with_rotation(rotation),
));
commands.spawn((
BoardStatic,
image_sprite(&art.plank, Color::WHITE, plank_size),
Transform::from_translation(center.extend(z::WALL)).with_rotation(rotation),
));
};
let mut corner_hits = vec![0u8; (board.width() as usize + 1) * (board.height() as usize + 1)];
let corner_index = |cx: u16, cy: u16| cy as usize * (board.width() as usize + 1) + cx as usize;
for (x, y, _) in board.tiles() {
let pos = layout::tile_center(board, x, y);
if board.wall_at(x, y, Direction::Up) {
plank(commands, pos + Vec2::new(0.0, TILE / 2.0), false);
corner_hits[corner_index(u16::from(x), u16::from(y))] += 1;
corner_hits[corner_index(u16::from(x) + 1, u16::from(y))] += 1;
}
if board.wall_at(x, y, Direction::Left) {
plank(commands, pos + Vec2::new(-TILE / 2.0, 0.0), true);
corner_hits[corner_index(u16::from(x), u16::from(y))] += 1;
corner_hits[corner_index(u16::from(x), u16::from(y) + 1)] += 1;
}
if y == board.height() - 1 && board.wall_at(x, y, Direction::Down) {
plank(commands, pos + Vec2::new(0.0, -TILE / 2.0), false);
}
if x == board.width() - 1 && board.wall_at(x, y, Direction::Right) {
plank(commands, pos + Vec2::new(TILE / 2.0, 0.0), true);
}
}
let (w, h) = (
f32::from(board.width()) * TILE,
f32::from(board.height()) * TILE,
);
for cy in 1..u16::from(board.height()) {
for cx in 1..u16::from(board.width()) {
if corner_hits[corner_index(cx, cy)] == 0 {
continue;
}
let corner = Vec2::new(
f32::from(cx) * TILE - w / 2.0,
h / 2.0 - f32::from(cy) * TILE,
);
commands.spawn((
BoardStatic,
image_sprite(&art.post, Color::WHITE, Vec2::splat(15.0)),
Transform::from_translation(corner.extend(z::WALL + 0.1)),
));
}
}
for (size, offset, rot) in [
(
Vec2::new(w + 36.0, 18.0),
Vec2::new(0.0, h / 2.0 + 9.0),
std::f32::consts::PI,
),
(
Vec2::new(w + 36.0, 18.0),
Vec2::new(0.0, -h / 2.0 - 9.0),
0.0,
),
(
Vec2::new(h + 36.0, 18.0),
Vec2::new(-w / 2.0 - 9.0, 0.0),
-std::f32::consts::FRAC_PI_2,
),
(
Vec2::new(h + 36.0, 18.0),
Vec2::new(w / 2.0 + 9.0, 0.0),
std::f32::consts::FRAC_PI_2,
),
] {
commands.spawn((
BoardStatic,
image_sprite(&art.wet, Color::WHITE, size),
Transform::from_translation(offset.extend(z::WET))
.with_rotation(Quat::from_rotation_z(rot)),
));
}
}
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;
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.signpost_at(sprite.x, sprite.y) {
Some(sp) if sp.dir == sprite.dir && sp.owner == sprite.owner => covered[idx] = true,
Some(_) | None => commands.entity(entity).despawn(),
}
}
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;
};
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,
paint_color(sp.owner),
Vec2::splat(SIGN_SIZE),
),
Transform::from_translation(Vec3::new(0.0, 0.0, 0.01)),
));
});
}
}
const SIGN_SHADOW: Color = Color::srgba(0.14, 0.10, 0.06, 0.34);
fn paint_color(owner: u8) -> Color {
palette::player_color(owner).lighter(0.08)
}
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>,
}
pub fn dress_signposts(
time: Res<Time>,
sim: Res<Sim>,
art: Res<Art>,
settings: Res<crate::app::settings::GameSettings>,
seating: crate::app::side_panels::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 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 = time.elapsed_secs();
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 = paint_color(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,
};
if *shown != want {
*shown = want;
}
if marked {
halo.color = 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();
}
coat.color = paint.with_alpha(alpha);
continue;
}
let Ok((mut shadow, mut shadow_tf)) = shadows.get_mut(*child) else {
continue;
};
shadow.color = 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);
}
}
}
#[derive(Component)]
pub struct TurnstileSprite {
x: u8,
y: u8,
right: bool,
}
pub fn sync_turnstiles(
mut commands: Commands,
sim: Res<Sim>,
art: Res<Art>,
mut existing: Query<(Entity, &mut TurnstileSprite)>,
) {
let board = &sim.0;
let mut covered: Vec<(u8, u8)> = Vec::new();
for (entity, mut sprite) in &mut existing {
if !on_board(board, sprite.x, sprite.y) {
commands.entity(entity).despawn();
continue;
}
match board.tile_at(sprite.x, sprite.y) {
TileKind::Turnstile { next_right } => {
if next_right != sprite.right {
sprite.right = next_right;
}
covered.push((sprite.x, sprite.y));
}
TileKind::Empty
| TileKind::Rock
| TileKind::Castle(_)
| TileKind::Spawner(_)
| TileKind::Kelp
| TileKind::Pool => commands.entity(entity).despawn(),
}
}
for (x, y, kind) in board.tiles() {
let TileKind::Turnstile { next_right } = kind else {
continue;
};
if covered.contains(&(x, y)) {
continue;
}
let tilt = if next_right { -0.6 } else { 0.6 };
commands.spawn((
TurnstileSprite {
x,
y,
right: next_right,
},
image_sprite(&art.log, Color::WHITE, Vec2::splat(TILE * 0.98)),
Transform::from_translation(
layout::tile_center(board, x, y).extend(z::TILE_FEATURE + 0.1),
)
.with_rotation(Quat::from_rotation_z(tilt)),
));
}
}
#[derive(Component)]
pub struct SpawnerSprite {
period: u32,
at: (u8, u8),
}
pub fn pulse_spawners(sim: Res<Sim>, mut holes: Query<(&SpawnerSprite, &mut Transform)>) {
let tick = sim.0.ticks();
let called = sim.0.golden_call().map(|(_, x, y)| (x, y));
for (hole, mut transform) in &mut holes {
if called == Some(hole.at) {
transform.scale = Vec3::splat(1.3);
continue;
}
if hole.period == 0 {
continue;
}
let phase = (tick % u64::from(hole.period)) as f32 / hole.period as f32;
let swell = if phase > 0.75 {
1.0 + (phase - 0.75) * 0.6
} else {
1.0
};
transform.scale = Vec3::splat(swell);
}
}
pub fn animate_turnstiles(time: Res<Time>, mut logs: Query<(&TurnstileSprite, &mut Transform)>) {
for (sprite, mut transform) in &mut logs {
let target = if sprite.right { -0.6 } else { 0.6 };
let (_, _, current) = transform.rotation.to_euler(EulerRot::XYZ);
let next = current + (target - current) * (time.delta_secs() * 14.0).min(1.0);
if (next - current).abs() > 0.0005 {
transform.rotation = Quat::from_rotation_z(next);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn neighbouring_puddles_turn_their_own_ways() {
for y in 0..6u8 {
for x in 0..6u8 {
let turn = pool_turn(tile_hash(x, y));
assert!((0.0..std::f32::consts::TAU).contains(&turn), "{turn}");
assert_ne!(
tile_hash(x, y),
tile_hash(x + 1, y),
"({x}, {y}) and its right"
);
assert_ne!(tile_hash(x, y), tile_hash(x, y + 1), "({x}, {y}) and below");
}
}
}
#[test]
fn a_level_keeps_its_own_beach() {
let seed = |w, h, s| margin_seed(&Board::new(w, h, s));
assert_eq!(seed(12, 9, 7), seed(12, 9, 7));
assert_ne!(seed(12, 9, 7), seed(12, 9, 8), "another seed");
assert_ne!(seed(12, 9, 7), seed(9, 12, 7), "the same area turned round");
}
#[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_margin_keeps_off_the_board_and_out_of_the_sea() {
let size = Vec2::new(12.0, 9.0) * TILE;
let shore = size.y / 2.0 + 26.0 + TILE * 0.55;
let mut rng = crate::app::effects::VisualRng::seeded(7);
let mut placed = 0;
for _ in 0..5000 {
let Some(at) = margin_spot(&mut rng, size, shore) else {
continue;
};
placed += 1;
let off_board = at.x.abs() > size.x / 2.0 + super::super::RIM
|| at.y.abs() > size.y / 2.0 + super::super::RIM;
assert!(off_board, "{at} is on the board or its tide");
assert!(at.y < shore, "{at} is in the sea");
}
assert!(placed > 1000, "only {placed} of 5000 found a place");
}
fn pond(width: u8, height: u8, pools: &[(u8, u8)]) -> Vec<PondPiece> {
let mut board = Board::new(width, height, 1);
for &(x, y) in pools {
board.set_tile(x, y, TileKind::Pool);
}
pond_pieces(&board)
}
fn count(pieces: &[PondPiece]) -> (usize, usize, usize) {
let tiles = pieces
.iter()
.filter(|p| matches!(p, PondPiece::Tile { .. }));
let bridges = pieces
.iter()
.filter(|p| matches!(p, PondPiece::Bridge { .. }));
let corners = pieces
.iter()
.filter(|p| matches!(p, PondPiece::Corner { .. }));
(tiles.count(), bridges.count(), corners.count())
}
#[test]
fn a_lone_pool_is_one_puddle() {
assert_eq!(count(&pond(5, 5, &[(2, 2)])), (1, 0, 0));
}
#[test]
fn a_lane_is_bridged_between_every_pair() {
let across = pond(6, 3, &[(1, 1), (2, 1), (3, 1)]);
assert_eq!(count(&across), (3, 2, 0));
let down = pond(3, 6, &[(1, 1), (1, 2), (1, 3)]);
assert_eq!(count(&down), (3, 2, 0));
for piece in down {
if let PondPiece::Bridge { at, turn } = piece {
assert_eq!(at.x, 0.0, "down the middle column");
assert_eq!(at.y % TILE, 0.0, "on the edge between two rows: {at}");
assert_eq!(turn, std::f32::consts::FRAC_PI_2, "along the lane");
}
}
}
#[test]
fn a_square_of_pools_has_no_dry_middle() {
let square = pond(4, 4, &[(1, 1), (2, 1), (1, 2), (2, 2)]);
assert_eq!(count(&square), (4, 4, 1));
let corner = square.iter().find_map(|p| {
if let PondPiece::Corner { at, .. } = p {
Some(*at)
} else {
None
}
});
assert_eq!(corner, Some(Vec2::ZERO), "the square's middle, dead centre");
}
#[test]
fn diagonal_pools_stay_apart() {
assert_eq!(count(&pond(4, 4, &[(1, 1), (2, 2)])), (2, 0, 0));
}
#[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;
}
}
}