use crate::app::editor::EditorState;
use crate::app::lobby::LobbyState;
use crate::app::net::Online;
use crate::app::settings::GameSettings;
use crate::app::{Bots, Campaign, Phase, Playback, Screen, Seats, Sim, VersusPhase};
use crate::app::{menu_ui, palette};
use bevy::prelude::*;
mod text;
use crate::app::palette::CLOCK_CALM;
pub(crate) use text::{clock_color, clock_into, event_name, urgency_band};
#[derive(Component)]
pub struct LevelLabel;
#[derive(Component)]
pub struct PostsLabel;
#[derive(Component)]
pub struct PromptLabel;
#[derive(Component)]
pub struct HeaderBar;
pub fn spawn_hud(
mut commands: Commands,
settings: Res<GameSettings>,
art: Res<crate::app::art::Art>,
) {
let font = TextFont {
font_size: FontSize::Px(22.0),
..default()
};
commands
.spawn((
HeaderBar,
Node {
position_type: PositionType::Absolute,
top: Val::Px(0.0),
left: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Px(menu_ui::HEADER_H),
padding: UiRect::horizontal(Val::Px(12.0)),
align_items: AlignItems::Center,
justify_content: JustifyContent::SpaceBetween,
column_gap: Val::Px(18.0),
..default()
},
BackgroundColor(palette::HEADER_FILL),
))
.with_children(|bar| {
bar.spawn((
TitleBox,
Node {
overflow: Overflow::clip_x(),
flex_shrink: 0.0,
..default()
},
))
.with_children(|box_| {
box_.spawn((
LevelLabel,
Text::new(""),
font.clone(),
TextColor(palette::HUD_INK),
TextLayout::no_wrap(),
));
});
bar.spawn((
PostsLabel,
Text::new(""),
font.clone(),
TextColor(palette::HUD_INK),
));
});
commands.spawn((
PromptLabel,
Text::new(""),
font,
TextColor(palette::SELECTED_ROW),
Node {
position_type: PositionType::Absolute,
bottom: Val::Px(8.0),
left: Val::Px(12.0),
max_width: Val::Percent(88.0),
padding: UiRect::axes(Val::Px(12.0), Val::Px(5.0)),
border_radius: BorderRadius::all(Val::Px(11.0)),
..default()
},
BackgroundColor(palette::PILL_FILL),
));
commands
.spawn((Node {
position_type: PositionType::Absolute,
top: Val::Px(menu_ui::UNDER_HEADER),
left: Val::Px(0.0),
width: Val::Percent(100.0),
justify_content: JustifyContent::Center,
..default()
},))
.with_children(|wrap| {
wrap.spawn((
HintLabel,
Text::new(""),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::ROW),
..default()
},
TextColor(palette::SELECTED_ROW.with_alpha(0.85)),
));
});
commands
.spawn((
Node {
position_type: PositionType::Absolute,
top: Val::Px(2.0),
left: Val::Px(0.0),
width: Val::Percent(100.0),
justify_content: JustifyContent::Center,
..default()
},
ZIndex(1),
))
.with_children(|wrap| {
wrap.spawn((
TideClock,
Text::new(""),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::DISPLAY),
..default()
},
TextColor(CLOCK_CALM),
));
});
spawn_field_guide(&mut commands, settings.tr(), &art);
}
#[derive(Component)]
pub struct TitleBox;
const TITLE_SHARE_WITH_CLOCK: f32 = 44.0;
fn title_width(screen: Screen) -> Val {
match screen {
Screen::Puzzle => Val::Percent(TITLE_SHARE_WITH_CLOCK),
Screen::Menu
| Screen::Versus
| Screen::Editor
| Screen::Lobby
| Screen::Settings
| Screen::Controls
| Screen::MatchSetup
| Screen::Achievements
| Screen::StageSelect
| Screen::Replays
| Screen::Interlude
| Screen::Language
| Screen::NewVersion => Val::Auto,
}
}
#[derive(Component)]
pub struct HintLabel;
#[derive(Component)]
pub struct TideClock;
pub(crate) const KEY_LESSON_LEVEL: &str = "Welcome Ashore";
#[allow(clippy::too_many_arguments)]
pub fn update_hint(
campaign: Res<Campaign>,
screen: Res<State<Screen>>,
phase: Res<State<Phase>>,
settings: Res<GameSettings>,
caps: Res<crate::app::keycaps::KeyCaps>,
stuck: Res<crate::app::hint::Hints>,
denied: Res<crate::app::hint::DeniedNote>,
mut hints: Query<&mut Text, With<HintLabel>>,
) {
let line = if *screen.get() != Screen::Puzzle {
String::new()
} else {
crate::app::hint::hint_line(settings.tr(), &stuck, &denied, phase.get()).unwrap_or_else(
|| {
let name = &campaign.current().name;
let honest = name != KEY_LESSON_LEVEL || settings.stock_legend();
if *phase.get() == Phase::Setup && honest {
caps.legend(settings.language.level_hint(name).unwrap_or(""))
} else {
String::new()
}
},
)
};
for mut text in &mut hints {
menu_ui::set_text(&mut text, &line);
}
}
pub fn update_tide_clock(
sim: Res<Sim>,
screen: Res<State<Screen>>,
phase: Res<State<Phase>>,
time: Res<Time>,
settings: Res<GameSettings>,
mut clocks: Query<(&mut Text, &mut TextColor), With<TideClock>>,
mut line: Local<String>,
) {
let remaining = match screen.get() {
Screen::Versus => None,
Screen::Puzzle if *phase.get() == Phase::Running => sim.0.remaining_ticks().or(Some(
crate::sim::PUZZLE_TICK_LIMIT.saturating_sub(sim.0.ticks()),
)),
Screen::Puzzle if *phase.get() == Phase::Setup => sim.0.remaining_ticks(),
Screen::Puzzle
| Screen::Menu
| Screen::Editor
| Screen::Lobby
| Screen::Settings
| Screen::Controls
| Screen::MatchSetup
| Screen::Achievements
| Screen::StageSelect
| Screen::Replays
| Screen::Interlude
| Screen::Language
| Screen::NewVersion => None,
};
for (mut text, mut color) in &mut clocks {
let Some(ticks) = remaining else {
if !text.0.is_empty() {
text.0.clear();
}
continue;
};
clock_into(&mut line, ticks);
menu_ui::set_text(&mut text, &line);
let target = clock_color(
ticks,
sim.0.round_length(),
time.elapsed_secs(),
!settings.reduced_motion,
);
menu_ui::set_color(&mut color, target);
}
}
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
pub fn update_hud(
sim: Res<Sim>,
campaign: Res<Campaign>,
screen: Res<State<Screen>>,
phase: Res<State<Phase>>,
vphase: Res<State<VersusPhase>>,
editor: Res<EditorState>,
online: Res<Online>,
playback: Res<Playback>,
lobby: Res<LobbyState>,
seats: Res<Seats>,
(settings, keycaps, names): (
Res<GameSettings>,
Res<crate::app::keycaps::KeyCaps>,
Res<crate::app::SeatNames>,
),
bots: Res<Bots>,
library: Res<crate::app::replays::Library>,
(notice, match_menu, coop): (
Res<crate::app::RoundNotice>,
Res<crate::app::match_setup::MatchMenu>,
Res<crate::app::Coop>,
),
(speed, tournament, pause_menu, spectators): (
Res<crate::app::replays::PlaybackSpeed>,
Res<crate::app::tournament::Tournament>,
Res<crate::app::pause::PauseMenu>,
Res<crate::app::spectators::SpectatorChat>,
),
mut labels: ParamSet<(
Query<&mut Text, With<LevelLabel>>,
Query<&mut Text, With<PostsLabel>>,
Query<(&mut Text, &mut Node), With<PromptLabel>>,
Query<&mut Node, With<TitleBox>>,
)>,
) {
let tr = settings.tr();
let lang = settings.language;
let said = text::screen_text(
*screen.get(),
&text::Readout {
tr,
lang,
sim: &sim,
campaign: &campaign,
coop: coop.in_play(&campaign),
phase: &phase,
vphase: &vphase,
editor: &editor,
online: &online,
playback: &playback,
lobby: &lobby,
tournament: &tournament,
seats: &seats,
settings: &settings,
keycaps: &keycaps,
names: &names,
bots: &bots,
library: &library,
notice: ¬ice,
match_menu: &match_menu,
paused: pause_menu.open,
spectator_typing: spectators.0.as_deref(),
crowd: online
.0
.as_ref()
.map_or_else(Default::default, |session| session.stands.crowd),
speed: speed.0,
},
);
if let Ok(mut text) = labels.p0().single_mut() {
menu_ui::set_text(&mut text, &said.title);
}
if let Ok(mut node) = labels.p3().single_mut() {
let wanted = title_width(*screen.get());
if node.width != wanted {
node.width = wanted;
}
}
if let Ok(mut text) = labels.p1().single_mut() {
menu_ui::set_text(&mut text, &said.status);
}
if let Ok((mut text, mut node)) = labels.p2().single_mut() {
menu_ui::set_text(&mut text, &said.prompt);
menu_ui::set_shown(&mut node, !said.prompt.is_empty());
}
}
#[derive(Component)]
pub struct FieldGuide;
#[derive(Component)]
pub struct FieldGuideNote(usize);
const KINDS: [crate::sim::CrabKind; 6] = [
crate::sim::CrabKind::Common,
crate::sim::CrabKind::Juvenile,
crate::sim::CrabKind::Giant,
crate::sim::CrabKind::Molting,
crate::sim::CrabKind::Golden,
crate::sim::CrabKind::Sparkling,
];
fn field_guide_note(tr: &crate::app::i18n::Tr, i: usize) -> String {
let kind = KINDS[i];
let note = tr.crab_notes[i];
if note.is_empty() {
kind.value().to_string()
} else {
format!("{} {note}", kind.value())
}
}
pub fn update_field_guide(
settings: Res<GameSettings>,
mut notes: Query<(&FieldGuideNote, &mut Text)>,
) {
if !settings.is_changed() {
return;
}
let tr = settings.tr();
for (note, mut text) in &mut notes {
menu_ui::set_text(&mut text, &field_guide_note(tr, note.0));
}
}
fn spawn_field_guide(
commands: &mut Commands,
tr: &crate::app::i18n::Tr,
art: &crate::app::art::Art,
) {
commands
.spawn((
FieldGuide,
Node {
position_type: PositionType::Absolute,
bottom: Val::Px(60.0),
left: Val::Px(0.0),
width: Val::Percent(100.0),
justify_content: JustifyContent::Center,
..default()
},
))
.with_children(|row| {
row.spawn((
Node {
column_gap: Val::Px(10.0),
align_items: AlignItems::Center,
padding: UiRect::axes(Val::Px(12.0), Val::Px(4.0)),
border_radius: BorderRadius::all(Val::Px(11.0)),
..default()
},
BackgroundColor(palette::PILL_FILL),
))
.with_children(|strip| {
for (i, kind) in KINDS.iter().enumerate() {
strip.spawn((
ImageNode::new(art.crab.clone())
.with_color(crate::app::creatures::body_color(*kind)),
Node {
width: Val::Px(20.0),
height: Val::Px(20.0),
..default()
},
));
strip.spawn((
FieldGuideNote(i),
Text::new(field_guide_note(tr, i)),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::FINE),
..default()
},
TextLayout::no_wrap(),
TextColor(palette::PARCHMENT.with_alpha(0.85)),
));
}
});
});
}
pub fn field_guide_visibility(
screen: Res<State<Screen>>,
playback: Res<Playback>,
mut guides: Query<&mut Node, With<FieldGuide>>,
) {
let wanted = match screen.get() {
Screen::Versus | Screen::Puzzle => true,
Screen::Menu
| Screen::Editor
| Screen::Lobby
| Screen::Settings
| Screen::Controls
| Screen::MatchSetup
| Screen::Achievements
| Screen::StageSelect
| Screen::Replays
| Screen::Interlude
| Screen::Language
| Screen::NewVersion => false,
};
let wanted = wanted && playback.0.is_none();
for mut node in &mut guides {
menu_ui::set_shown(&mut node, wanted);
}
}
pub fn header_backdrop(
screen: Res<State<Screen>>,
mut bars: Query<&mut BackgroundColor, With<HeaderBar>>,
) {
let target = if *screen.get() == Screen::Menu {
Color::NONE
} else {
palette::HEADER_FILL
};
for mut bg in &mut bars {
menu_ui::set_bg(&mut bg, target);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::i18n::Lang;
#[test]
fn a_long_level_name_stops_before_the_clock() {
use crate::app::i18n::metrics::text_px;
use crate::app::settings::DESIGN_W;
let clock = text_px("10:00", menu_ui::type_scale::DISPLAY);
let clock_left = DESIGN_W / 2.0 - clock / 2.0;
let Val::Percent(share) = title_width(Screen::Puzzle) else {
panic!("the puzzle title is held to a share of the bar");
};
let title_right = DESIGN_W * share / 100.0;
assert!(
title_right < clock_left,
"the title reaches {title_right:.0}px and the clock starts at {clock_left:.0}px"
);
let name = "W".repeat(28);
let worst = crate::app::i18n::ALL_LANGS
.into_iter()
.map(|lang| {
let tr = lang.tr();
[tr.title_tide_pool, tr.title_beach_day, tr.stage_custom]
.into_iter()
.map(|section| text_px(&format!("{section} 100/100 - {name}"), 22.0))
.fold(0.0f32, f32::max)
})
.fold(0.0f32, f32::max);
assert!(
worst > title_right,
"nothing to clip: the longest header is {worst:.0}px into {title_right:.0}px"
);
assert_eq!(title_width(Screen::Editor), Val::Auto);
assert_eq!(title_width(Screen::Versus), Val::Auto);
}
#[test]
fn only_the_key_lesson_names_keys() {
let names_keys = |hint: &str| hint.contains("WASD") || hint.contains("arrow key");
assert!(names_keys(Lang::En.level_hint(KEY_LESSON_LEVEL).unwrap()));
for level in crate::sim::campaign_levels() {
if level.name != KEY_LESSON_LEVEL
&& let Some(hint) = Lang::En.level_hint(&level.name)
{
assert!(!names_keys(hint), "{:?} names keys too", level.name);
}
}
}
}