use crate::app::editor::EditorState;
use crate::app::lobby::LobbyState;
use crate::app::settings::GameSettings;
use crate::app::{Campaign, Phase, Playback, Screen, 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;
const PROMPT_SHARE: f32 = 0.88;
const PROMPT_PAD_X: f32 = 12.0;
const PROMPT_MIN_PX: f32 = menu_ui::type_scale::FINE;
fn prompt_fit(natural: f32, room: f32) -> Option<f32> {
if natural <= room {
return Some(HEADER_PX);
}
let size = HEADER_PX * room / natural;
(size >= PROMPT_MIN_PX).then_some(size)
}
type PromptText = (
&'static Text,
&'static mut TextFont,
&'static mut TextLayout,
&'static bevy::text::TextLayoutInfo,
);
pub fn fit_prompt(
viewport: menu_ui::Viewport,
guides: Query<&Node, (With<FieldGuide>, Without<PromptLabel>)>,
mut prompts: Query<PromptText, With<PromptLabel>>,
mut too_long: Local<Option<String>>,
) {
let legend_up = guides.iter().any(|node| node.display != Display::None);
let Some(size) = viewport.size() else {
return;
};
let room = size.x * PROMPT_SHARE - 2.0 * PROMPT_PAD_X;
for (text, mut font, mut layout, info) in &mut prompts {
let FontSize::Px(now) = font.font_size else {
continue;
};
let given_up = too_long.as_deref() == Some(text.0.as_str());
let (size, wrap) = if legend_up && given_up {
(PROMPT_MIN_PX, true)
} else if legend_up && info.size.x > 0.0 {
let drawn = info.size.x / info.scale_factor.max(f32::EPSILON);
let natural = drawn * HEADER_PX / now;
let one_line = layout.linebreak == LineBreak::NoWrap;
match prompt_fit(natural, room) {
Some(size) if one_line => (size, false),
Some(_) => (HEADER_PX, false),
None => {
*too_long = Some(text.0.clone());
(PROMPT_MIN_PX, true)
}
}
} else {
(HEADER_PX, true)
};
let size = (size * 2.0).floor() / 2.0;
if (now - size).abs() > 0.01 {
font.font_size = FontSize::Px(size);
}
let linebreak = if wrap {
LineBreak::WordBoundary
} else {
LineBreak::NoWrap
};
if layout.linebreak != linebreak {
layout.linebreak = linebreak;
}
}
}
#[derive(Component)]
pub struct HeaderBar;
const HEADER_PX: f32 = 22.0;
pub fn spawn_hud(
mut commands: Commands,
settings: Res<GameSettings>,
art: Res<crate::app::art::Art>,
sim: Res<crate::app::Sim>,
) {
let font = menu_ui::display_font(HEADER_PX);
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(""),
menu_ui::display_font(menu_ui::type_scale::DISPLAY),
TextColor(CLOCK_CALM),
));
});
spawn_field_guide(&mut commands, settings.tr(), &art, &sim.0);
}
#[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";
#[derive(bevy::ecs::system::SystemParam)]
pub struct HintNotes<'w> {
stuck: Res<'w, crate::app::hint::Hints>,
denied: Res<'w, crate::app::hint::DeniedNote>,
}
pub fn update_hint(
campaign: Res<Campaign>,
screen: Res<State<Screen>>,
phase: Res<State<Phase>>,
settings: Res<GameSettings>,
caps: Res<crate::app::keycaps::KeyCaps>,
notes: HintNotes,
mut hints: Query<&mut Text, With<HintLabel>>,
) {
let HintNotes { stuck, denied } = notes;
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(
campaign
.hint(campaign.index, settings.language)
.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);
}
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct Modes<'w> {
screen: Res<'w, State<Screen>>,
phase: Res<'w, State<Phase>>,
vphase: Res<'w, State<VersusPhase>>,
editor: Res<'w, EditorState>,
lobby: Res<'w, LobbyState>,
match_menu: Res<'w, crate::app::match_setup::MatchMenu>,
pause_menu: Res<'w, crate::app::pause::PauseMenu>,
coop: Res<'w, crate::app::Coop>,
}
#[derive(bevy::ecs::system::SystemParam)]
pub struct HudSources<'w> {
modes: Modes<'w>,
seating: crate::app::side_panels::Seating<'w>,
sim: Res<'w, Sim>,
campaign: Res<'w, Campaign>,
settings: Res<'w, GameSettings>,
keycaps: Res<'w, crate::app::keycaps::KeyCaps>,
library: Res<'w, crate::app::replays::Library>,
notice: Res<'w, crate::app::RoundNotice>,
speed: Res<'w, crate::app::replays::PlaybackSpeed>,
tournament: Res<'w, crate::app::tournament::Tournament>,
spectators: Res<'w, crate::app::spectators::SpectatorChat>,
}
impl HudSources<'_> {
fn readout(&self) -> text::Readout<'_> {
let Self {
modes,
seating,
sim,
campaign,
settings,
keycaps,
library,
notice,
speed,
tournament,
spectators,
} = self;
text::Readout {
tr: settings.tr(),
lang: settings.language,
sim,
campaign,
coop: modes.coop.in_play(campaign),
phase: &modes.phase,
vphase: &modes.vphase,
editor: &modes.editor,
online: &seating.online,
playback: &seating.playback,
lobby: &modes.lobby,
tournament,
seats: &seating.seats,
settings,
keycaps,
names: &seating.names,
controllers: &seating.controllers,
library,
notice,
match_menu: &modes.match_menu,
paused: modes.pause_menu.stops_the_round(),
spectator_typing: spectators.0.as_deref(),
crowd: seating
.online
.0
.as_ref()
.map_or_else(Default::default, |session| session.stands.crowd),
speed: speed.0,
}
}
}
type HudLabels<'w, 's> = ParamSet<
'w,
's,
(
Query<'static, 'static, &'static mut Text, With<LevelLabel>>,
Query<'static, 'static, &'static mut Text, With<PostsLabel>>,
Query<'static, 'static, (&'static mut Text, &'static mut Node), With<PromptLabel>>,
Query<'static, 'static, &'static mut Node, With<TitleBox>>,
),
>;
pub fn update_hud(sources: HudSources, mut labels: HudLabels) {
let screen = sources.modes.screen.get();
let said = text::screen_text(*screen, &sources.readout());
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);
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 LEGEND_CRAB_PX: f32 = 26.0;
const LEGEND_SAND: Color = Color::srgb(0.84, 0.77, 0.62);
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, board: &crate::sim::Board) -> String {
let worth = board.crab_value(KINDS[i]);
let note = tr.crab_notes[i];
if note.is_empty() {
worth.to_string()
} else {
format!("{worth} {note}")
}
}
pub fn update_field_guide(
settings: Res<GameSettings>,
sim: Res<crate::app::Sim>,
mut notes: Query<(&FieldGuideNote, &mut Text)>,
mut golden_shown: Local<u32>,
) {
let golden = sim.0.crab_value(crate::sim::CrabKind::Golden);
if !settings.is_changed() && golden == *golden_shown {
return;
}
*golden_shown = golden;
let tr = settings.tr();
for (note, mut text) in &mut notes {
menu_ui::set_text(&mut text, &field_guide_note(tr, note.0, &sim.0));
}
}
fn spawn_field_guide(
commands: &mut Commands,
tr: &crate::app::i18n::Tr,
art: &crate::app::art::Art,
board: &crate::sim::Board,
) {
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() {
let color = crate::app::creatures::body_color(*kind);
let layer = |image: &Handle<Image>| {
(
ImageNode::new(image.clone()).with_color(color),
Node {
position_type: PositionType::Absolute,
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
)
};
strip
.spawn((
Node {
width: Val::Px(LEGEND_CRAB_PX),
height: Val::Px(LEGEND_CRAB_PX),
border_radius: BorderRadius::MAX,
..default()
},
BackgroundColor(LEGEND_SAND),
))
.with_children(|icon| {
icon.spawn(layer(&art.crab));
icon.spawn(layer(&art.claw));
});
strip.spawn((
FieldGuideNote(i),
Text::new(field_guide_note(tr, i, board)),
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 the_field_guide_quotes_the_boards_golden_worth() {
let golden = KINDS
.iter()
.position(|&k| k == crate::sim::CrabKind::Golden)
.expect("the golden crab is in the guide");
let tr = Lang::En.tr();
let mut board = crate::sim::Board::new(9, 7, 1);
assert!(field_guide_note(tr, golden, &board).starts_with("50 "));
crate::sim::set_versus_rules(&mut board, 240);
let note = field_guide_note(tr, golden, &board);
assert!(
note.starts_with(&format!("{} ", crate::sim::GOLDEN_CALLED_VALUE)),
"{note}"
);
}
#[test]
fn a_prompt_shrinks_to_its_room_and_no_further() {
assert_eq!(prompt_fit(900.0, 1000.0), Some(HEADER_PX));
let size = prompt_fit(1100.0, 1000.0).expect("a tenth too wide fits smaller");
assert!((size - HEADER_PX * 1000.0 / 1100.0).abs() < 0.01, "{size}");
assert_eq!(
prompt_fit(2000.0, 1000.0),
None,
"under the fine print: wrap"
);
}
#[test]
fn every_prompt_fits_one_line_at_720p() {
use crate::app::i18n::ALL_LANGS;
use crate::app::i18n::metrics::display_px;
let room = 1280.0 * PROMPT_SHARE - 2.0 * PROMPT_PAD_X;
for lang in ALL_LANGS {
let tr = lang.tr();
for (field, line) in tr.strings() {
if !field.starts_with("prompt_") {
continue;
}
let prompt = format!("{line} | {}", tr.prompt_mute);
let natural = display_px(&prompt, HEADER_PX);
assert!(
prompt_fit(natural, room).is_some(),
"{lang:?} {field} is {natural:.0}px at full size, too long for one line: {prompt}"
);
}
}
}
#[test]
fn a_long_level_name_stops_before_the_clock() {
use crate::app::i18n::metrics::display_px;
use crate::app::settings::DESIGN_W;
let clock = display_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| display_px(&format!("{section} 100/100 - {name}"), HEADER_PX))
.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);
}
}
}
}