use crate::app::editor::EditorState;
use crate::app::i18n::{Tr, fill};
use crate::app::lobby::LobbyState;
use crate::app::net::Online;
use crate::app::settings::GameSettings;
use crate::app::teams::TeamMode;
use crate::app::{Bots, Campaign, CampaignKind, Phase, Playback, Screen, Seats, Sim, VersusPhase};
use crate::sim::{Goal, LURE_TICKS, TideEvent};
use bevy::prelude::*;
pub(super) const CLOCK_CALM: Color = Color::srgb(0.95, 0.93, 0.84);
pub(super) const CLOCK_RED: Color = Color::srgb(0.96, 0.25, 0.18);
pub(super) const CLOCK_RED_BRIGHT: Color = Color::srgb(1.0, 0.55, 0.25);
pub(crate) fn clock_text(ticks: u64) -> String {
let secs = ticks / u64::from(crate::sim::TICKS_PER_SECOND);
format!("{}:{:02}", secs / 60, secs % 60)
}
pub(crate) fn clock_color(ticks: u64, elapsed: f32, blink: bool) -> Color {
let secs = ticks / u64::from(crate::sim::TICKS_PER_SECOND);
if secs <= 10 {
if !blink || ((elapsed * 2.0) as u32).is_multiple_of(2) {
CLOCK_RED
} else {
CLOCK_RED_BRIGHT
}
} else if secs <= 30 {
CLOCK_RED
} else {
CLOCK_CALM
}
}
pub(super) struct HudText {
pub title: String,
pub status: String,
pub prompt: String,
}
impl HudText {
fn new(
title: impl Into<String>,
status: impl Into<String>,
prompt: impl Into<String>,
) -> HudText {
HudText {
title: title.into(),
status: status.into(),
prompt: prompt.into(),
}
}
}
pub(super) fn lobby_text(tr: &Tr, lobby: &LobbyState) -> HudText {
let title = tr.title_lobby.to_string();
let status = lobby.feedback.clone();
use crate::app::lobby::Standing;
let prompt = match lobby.standing() {
Standing::ChoosingToWatch => tr.lobby_watch_armed.to_string(),
Standing::Joining => tr.lobby_aboard_prompt.to_string(),
Standing::Hosting => tr.lobby_broadcasting.to_string(),
Standing::Choosing if lobby.hosts.is_empty() => tr.lobby_none_yet.to_string(),
Standing::Choosing => {
match lobby.hosts.len() {
1 => tr.lobby_join_list_one.to_string(),
n => fill(tr.lobby_join_list, &[("n", &n.to_string())]),
}
}
};
HudText::new(title, status, prompt)
}
pub(super) fn editor_text(tr: &Tr, editor: &EditorState, sim: &Sim) -> HudText {
let testing = editor.testing.is_some();
let kind = match editor.kind {
crate::sim::LevelKind::Puzzle => tr.ed_kind_puzzle,
crate::sim::LevelKind::Arena => tr.ed_kind_arena,
};
let title = if testing {
tr.title_playtest.to_string()
} else {
format!(
"{} [{kind}] - {}{}",
tr.title_editor,
editor.name,
if editor.naming { "_" } else { "" }
)
};
let counted = match editor.kind {
crate::sim::LevelKind::Puzzle => fill(tr.ed_posts, &[("n", &editor.posts.to_string())]),
crate::sim::LevelKind::Arena => {
fill(tr.ed_seats, &[("n", &sim.0.castle_seats().to_string())])
}
};
let status = fill(&counted, &[("msg", &editor.feedback)]);
let prompt = if testing {
tr.ed_playtest_prompt.to_string()
} else {
tr.ed_prompt.to_string()
};
HudText::new(title, status, prompt)
}
pub(super) fn puzzle_text(
tr: &Tr,
lang: crate::app::i18n::Lang,
campaign: &Campaign,
sim: &Sim,
phase: &State<Phase>,
custom_keys: bool,
) -> HudText {
let level = campaign.current();
let campaign_title = match campaign.kind {
CampaignKind::TidePool => tr.title_tide_pool,
CampaignKind::BeachDay => tr.title_beach_day,
};
let title = format!(
"{} {}/{} - {}",
campaign_title,
campaign.index + 1,
campaign.levels.len(),
lang.level_name(&level.name)
);
let used = sim.0.signpost_count(0);
let saved = fill(
tr.saved_count,
&[
("a", &sim.0.crabs_banked().to_string()),
(
"b",
&sim.0.crabs_spawned().max(level.crab_count()).to_string(),
),
],
);
let status = match level.goal {
Goal::AllCrabs => format!(
"{} | {saved}",
fill(
tr.signposts_count,
&[("a", &used.to_string()), ("b", &level.posts.to_string())]
)
),
Goal::Bank(n) => fill(
tr.goal_bank,
&[
("a", &sim.0.crabs_banked().to_string()),
("b", &n.to_string()),
("t", ""),
],
),
Goal::Survive => fill(tr.goal_survive, &[("t", "")]),
Goal::Golden => fill(tr.goal_golden, &[("t", "")]),
};
let prompt = match phase.get() {
Phase::Setup if level.posts == 0 => tr.prompt_setup_no_posts.to_string(),
Phase::Setup if used >= level.posts as usize => tr.prompt_setup_full.to_string(),
Phase::Setup if custom_keys => tr.prompt_setup_custom.to_string(),
Phase::Setup => tr.prompt_setup.to_string(),
Phase::Running => tr.prompt_running.to_string(),
Phase::Won if campaign.index + 1 == campaign.levels.len() => tr.last_level.to_string(),
Phase::Won => tr.prompt_won.to_string(),
Phase::Lost => tr.prompt_lost.to_string(),
};
HudText::new(title, status, prompt)
}
pub(super) fn versus_text(r: &Readout) -> HudText {
let Readout {
tr,
sim,
seats,
settings,
names,
playback,
online,
bots,
vphase,
speed,
..
} = *r;
let scores = sim.0.scores();
let team_mode = crate::app::teams::in_play(settings, online, seats.0);
let mut mode = if playback.0.is_some() {
tr.title_replay.to_string()
} else if let Some(session) = &online.0 {
match session.session.seat() {
Some(seat) => fill(tr.title_online, &[("p", &names.label(tr, seat))]),
None => tr.title_watching.to_string(),
}
} else if bots.0.iter().any(Option::is_some) {
let count = bots.0.iter().filter(|b| b.is_some()).count();
fill(
tr.title_vs_ai,
&[("n", &count.to_string()), ("p", &names.label(tr, 0))],
)
} else {
tr.title_turf_war.to_string()
};
if team_mode != TeamMode::Solo {
let totals = crate::app::teams::team_scores(scores, seats.0, team_mode)
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(" vs ");
mode = fill(tr.team_banner, &[("mode", &mode), ("s", &totals)]);
}
let mut status = if playback.0.is_some() && speed > 1 {
fill(tr.replay_speed, &[("n", &speed.to_string())])
} else if sim.0.in_surge() && !sim.0.round_over() {
tr.the_gulls.to_string()
} else {
String::new()
};
if let Some((event, at)) = sim.0.last_event()
&& sim.0.ticks().saturating_sub(at) < 90
{
status = fill(tr.tide_event, &[("e", event_name(tr, event))]);
}
if let Some((owner, remaining)) = sim.0.lure() {
let seat = names.label(tr, owner);
status = if remaining > LURE_TICKS - 60 {
fill(tr.lure_started, &[("p", &seat)])
} else {
fill(
tr.lure_banner,
&[("s", &remaining.div_ceil(30).to_string()), ("p", &seat)],
)
};
}
if let Some(session) = &online.0 {
if let Some(frame) = session.desync_at {
status = fill(tr.desync, &[("f", &frame.to_string())]);
} else if !session.transport.connected() {
status = tr.waiting_peer.to_string();
} else if let Some(seat) = session.waiting_on() {
status = fill(tr.waiting_for, &[("p", &names.label(tr, seat))]);
}
}
let prompt = match vphase.get() {
VersusPhase::Running
if playback.0.is_some() || online.0.as_ref().is_some_and(|s| s.session.watching()) =>
{
tr.prompt_enter_menu.to_string()
}
VersusPhase::Running if online.0.is_some() || bots.0.iter().any(Option::is_some) => {
tr.prompt_versus_short.to_string()
}
VersusPhase::Running if settings.custom_binds() => tr.prompt_versus_custom.to_string(),
VersusPhase::Running => tr.prompt_versus_local.to_string(),
VersusPhase::Over => tr.prompt_enter_menu.to_string(),
};
HudText::new(mode, status, prompt)
}
pub(crate) fn event_name(tr: &Tr, event: TideEvent) -> &'static str {
tr.events[event.index()]
}
pub(super) struct Readout<'a> {
pub tr: &'static Tr,
pub lang: crate::app::i18n::Lang,
pub sim: &'a Sim,
pub campaign: &'a Campaign,
pub phase: &'a State<Phase>,
pub vphase: &'a State<VersusPhase>,
pub editor: &'a EditorState,
pub online: &'a Online,
pub playback: &'a Playback,
pub lobby: &'a LobbyState,
pub seats: &'a Seats,
pub settings: &'a GameSettings,
pub names: &'a crate::app::SeatNames,
pub bots: &'a Bots,
pub library: &'a crate::app::replays::Library,
pub notice: &'a crate::app::RoundNotice,
pub match_menu: &'a crate::app::match_setup::MatchMenu,
pub speed: u8,
}
pub(super) fn screen_text(screen: Screen, r: &Readout) -> HudText {
let mut said = screen_text_for(screen, r);
if matches!(screen, Screen::Versus | Screen::Puzzle) {
said.prompt = format!("{} | {}", said.prompt, r.tr.prompt_mute);
}
said
}
pub(super) fn screen_text_for(screen: Screen, r: &Readout) -> HudText {
match screen {
Screen::Menu => HudText::new(
String::new(),
r.notice.0.clone(),
r.tr.menu_prompt.to_string(),
),
Screen::Settings => HudText::new(
r.tr.title_settings.to_string(),
String::new(),
r.tr.prompt_settings.to_string(),
),
Screen::Controls => HudText::new(
r.tr.title_controls.to_string(),
String::new(),
r.tr.prompt_controls.to_string(),
),
Screen::MatchSetup => HudText::new(
r.tr.title_match_setup.to_string(),
String::new(),
if matches!(
crate::app::match_setup::Row::ALL[r.match_menu.selected],
crate::app::match_setup::Row::Name(_)
) {
r.tr.prompt_match_name.to_string()
} else {
r.tr.prompt_match_setup.to_string()
},
),
Screen::Achievements => HudText::new(
r.tr.title_achievements.to_string(),
String::new(),
r.tr.prompt_esc_menu.to_string(),
),
Screen::Replays => HudText::new(
r.tr.title_replays.to_string(),
r.library.feedback.clone(),
r.tr.prompt_replays.to_string(),
),
Screen::StageSelect => HudText::new(
format!(
"{} - {}",
match r.campaign.kind {
CampaignKind::TidePool => r.tr.title_tide_pool,
CampaignKind::BeachDay => r.tr.title_beach_day,
},
r.tr.title_stages
),
String::new(),
r.tr.prompt_stages.to_string(),
),
Screen::Interlude => HudText::new(
r.tr.title_turf_war.to_string(),
String::new(),
String::new(),
),
Screen::Lobby => lobby_text(r.tr, r.lobby),
Screen::Editor => editor_text(r.tr, r.editor, r.sim),
Screen::Puzzle => puzzle_text(
r.tr,
r.lang,
r.campaign,
r.sim,
r.phase,
!r.settings.stock_legend(),
),
Screen::Language => HudText::new(
r.tr.title_pick_language.to_string(),
String::new(),
r.tr.prompt_pick_language.to_string(),
),
Screen::NewVersion => HudText::new(
r.tr.title_new_version.to_string(),
String::new(),
r.tr.prompt_new_version.to_string(),
),
Screen::Versus => versus_text(r),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::i18n::{EN, Lang};
use crate::sim::{Board, Level, campaign_levels};
#[test]
fn every_screen_names_itself_and_its_keys() {
use crate::app::{Bots, Campaign, CampaignKind, Playback, Seats};
use crate::sim::{Board, campaign_levels};
let levels = campaign_levels();
let builtins = levels.len();
let campaign = Campaign {
kind: CampaignKind::TidePool,
levels,
index: 0,
builtins,
};
let settings = GameSettings::default();
let readout = Readout {
tr: &EN,
lang: Lang::En,
sim: &Sim(Board::new(9, 7, 1)),
campaign: &campaign,
phase: &State::new(Phase::Setup),
vphase: &State::new(VersusPhase::Running),
editor: &EditorState::default(),
online: &Online::default(),
playback: &Playback::default(),
lobby: &LobbyState::default(),
seats: &Seats(2),
settings: &settings,
names: &crate::app::SeatNames::default(),
bots: &Bots::default(),
library: &crate::app::replays::Library::default(),
notice: &crate::app::RoundNotice::default(),
match_menu: &crate::app::match_setup::MatchMenu::default(),
speed: 1,
};
for screen in Screen::ALL {
let said = screen_text(screen, &readout);
let (title, prompt) = (&said.title, &said.prompt);
if screen != Screen::Menu {
assert!(!title.is_empty(), "{screen:?} has no header");
}
if screen != Screen::Interlude {
assert!(!prompt.is_empty(), "{screen:?} offers no keys");
}
}
}
#[test]
fn the_editor_header_follows_the_playtest_toggle() {
let mut editor = EditorState::default();
editor.posts = 3;
editor.name = "Gull Alley".into();
editor.feedback = "solvable: 2".into();
let mut board = Board::new(9, 7, 1);
board.set_tile(0, 0, crate::sim::TileKind::Castle(0));
board.set_tile(8, 6, crate::sim::TileKind::Castle(1));
let sim = Sim(board);
let HudText {
title,
status,
prompt,
} = editor_text(&EN, &editor, &sim);
assert!(title.starts_with(EN.title_editor), "{title}");
assert!(title.contains("Gull Alley"), "the name is on the header");
assert!(title.contains(EN.ed_kind_puzzle), "{title}");
assert!(status.contains('3') && status.contains("solvable: 2"));
assert_eq!(prompt, EN.ed_prompt);
editor.kind = crate::sim::LevelKind::Arena;
let beach = editor_text(&EN, &editor, &sim);
assert!(beach.title.contains(EN.ed_kind_arena), "{}", beach.title);
assert_eq!(beach.status, "seats: 2 | solvable: 2", "{}", beach.status);
editor.kind = crate::sim::LevelKind::Puzzle;
editor.naming = true;
let typing = editor_text(&EN, &editor, &sim).title;
assert!(typing.ends_with('_'), "{typing}");
editor.naming = false;
editor.testing = Some(Board::new(4, 4, 0));
let HudText { title, prompt, .. } = editor_text(&EN, &editor, &sim);
assert_eq!(title, EN.title_playtest);
assert_eq!(prompt, EN.ed_playtest_prompt);
}
#[test]
fn the_lobby_prompt_tracks_the_connection_state() {
let mut lobby = LobbyState::default();
assert_eq!(lobby_text(&EN, &lobby).prompt, EN.lobby_none_yet);
for at in 1..=3u8 {
lobby.hosts.push(crate::app::lobby::HostEntry {
addr: format!("10.0.0.{at}:47777").parse().expect("addr"),
id: u64::from(at),
name: String::new(),
host: String::new(),
taken: 1,
seats: 6,
running: false,
age: 0.0,
});
}
let prompt = lobby_text(&EN, &lobby).prompt;
assert!(prompt.contains('3'), "how many are out there: {prompt}");
lobby.feedback = "hosting on port 47777".into();
assert_eq!(lobby_text(&EN, &lobby).status, "hosting on port 47777");
}
#[test]
fn the_puzzle_prompt_reacts_to_a_spent_inventory() {
let levels = campaign_levels();
let builtins = levels.len();
let campaign = Campaign {
kind: CampaignKind::TidePool,
levels,
index: 0,
builtins,
};
let level: &Level = campaign.current();
let posts = level.posts;
let mut sim = Sim(level.board());
let phase = State::new(Phase::Setup);
let HudText { title, prompt, .. } =
puzzle_text(&EN, Lang::En, &campaign, &sim, &phase, false);
assert!(title.starts_with(EN.title_tide_pool), "{title}");
assert!(
title.contains("1/"),
"the level's place in the list: {title}"
);
assert_eq!(
prompt,
if posts == 0 {
EN.prompt_setup_no_posts
} else {
EN.prompt_setup
}
);
if posts > 0 {
let mut placed = 0;
'fill: for y in 0..sim.0.height() {
for x in 0..sim.0.width() {
if placed == posts {
break 'fill;
}
if sim.0.place_signpost(0, x, y, crate::sim::Direction::Up) {
placed += 1;
}
}
}
let prompt = puzzle_text(&EN, Lang::En, &campaign, &sim, &phase, false).prompt;
assert_eq!(prompt, EN.prompt_setup_full);
}
let prompt =
puzzle_text(&EN, Lang::En, &campaign, &Sim(level.board()), &phase, true).prompt;
assert_eq!(prompt, EN.prompt_setup_custom);
let one_hand = crate::app::settings::GameSettings {
ijkl_commits: true,
..crate::app::settings::GameSettings::default()
};
assert!(!one_hand.stock_legend());
assert!(crate::app::settings::GameSettings::default().stock_legend());
}
#[test]
fn every_tide_event_has_a_distinct_name() {
let names: Vec<&str> = TideEvent::ALL
.iter()
.map(|&event| event_name(&EN, event))
.collect();
for name in &names {
assert!(!name.is_empty());
}
let mut unique = names.clone();
unique.sort_unstable();
unique.dedup();
assert_eq!(
unique.len(),
names.len(),
"duplicate event names: {names:?}"
);
}
#[test]
fn clock_formats_minutes_and_seconds() {
let tps = u64::from(crate::sim::TICKS_PER_SECOND);
assert_eq!(clock_text(0), "0:00");
assert_eq!(clock_text(29 * tps), "0:29");
assert_eq!(clock_text(90 * tps), "1:30");
assert_eq!(clock_text(600 * tps), "10:00");
}
#[test]
fn the_clock_reddens_then_blinks() {
let tps = u64::from(crate::sim::TICKS_PER_SECOND);
assert_eq!(clock_color(60 * tps, 0.0, true), CLOCK_CALM);
assert_eq!(clock_color(20 * tps, 0.0, true), CLOCK_RED);
assert_eq!(clock_color(5 * tps, 0.0, true), CLOCK_RED);
assert_eq!(clock_color(5 * tps, 0.5, true), CLOCK_RED_BRIGHT);
assert_eq!(clock_color(5 * tps, 0.5, false), CLOCK_RED);
}
}