use super::{CARD_BORDER, LOG_TOP, card};
use crate::app::i18n::fill;
use crate::app::menu_ui;
use crate::app::palette;
use crate::app::settings::GameSettings;
use crate::app::sim_events::SimEvent;
use crate::sim::CrabKind;
use bevy::prelude::*;
use std::collections::VecDeque;
#[derive(Component)]
pub struct LogLine(pub usize);
pub struct LogEntry {
pub text: String,
pub color: Color,
pub count: u32,
}
impl LogEntry {
pub fn render_into(&self, out: &mut String) {
use std::fmt::Write;
out.clear();
out.push_str(&self.text);
if self.count > 1 {
let _ = write!(out, " ×{}", self.count);
}
}
#[cfg(test)]
pub fn rendered(&self) -> String {
let mut out = String::new();
self.render_into(&mut out);
out
}
}
#[derive(Resource, Default)]
pub struct EventLog(pub VecDeque<LogEntry>);
impl EventLog {
pub fn push(&mut self, text: String, color: Color) {
if let Some(index) = self.0.iter().position(|entry| entry.text == text) {
let mut entry = self.0.remove(index).expect("index came from the deque");
entry.count += 1;
self.0.push_front(entry);
return;
}
self.0.push_front(LogEntry {
text,
color,
count: 1,
});
self.0.truncate(LOG_LINES);
}
}
const LOG_LINES: usize = 16;
pub fn collect_chat(mut online: ResMut<crate::app::net::Online>, mut log: ResMut<EventLog>) {
let Some(session) = &mut online.0 else {
return;
};
for (who, line) in session.heard.drain(..) {
let said = match who.is_empty() {
true => line,
false => format!("{who}: {line}"),
};
log.push(said, palette::IDLE_ROW);
}
}
pub fn raid_line(
tr: &crate::app::i18n::Tr,
owner: u8,
lost: u32,
by: Option<u8>,
seat_label: &impl Fn(u8) -> String,
) -> String {
let n = lost.to_string();
let victim = seat_label(owner);
match by {
None => fill(tr.log_raid, &[("p", &victim), ("n", &n)]),
Some(by) if by == owner => fill(tr.log_raid_own, &[("p", &victim), ("n", &n)]),
Some(by) => fill(
tr.log_raid_by,
&[("a", &seat_label(by)), ("p", &victim), ("n", &n)],
),
}
}
pub fn event_line(
tr: &crate::app::i18n::Tr,
event: crate::sim::TideEvent,
by: Option<u8>,
seat_label: &impl Fn(u8) -> String,
) -> (String, Color) {
let name = crate::app::hud::event_name(tr, event);
match by {
Some(seat) => (
fill(tr.log_event_by, &[("p", &seat_label(seat)), ("e", name)]),
palette::seat_text(seat),
),
None => (name.to_string(), palette::INK_TIDE),
}
}
pub fn collect_log(
mut events: MessageReader<SimEvent>,
settings: Res<GameSettings>,
names: Res<crate::app::SeatNames>,
mut log: ResMut<EventLog>,
) {
let tr = settings.tr();
let seat_label = |seat: u8| names.label(tr, seat);
for event in events.read() {
let line = match event {
SimEvent::CastleRaided {
owner, lost, by, ..
} => Some((
raid_line(tr, *owner, *lost, *by, &seat_label),
palette::INK_RAID,
)),
SimEvent::CrabBanked {
owner,
points,
kind: CrabKind::Golden,
..
} => Some((
fill(
tr.log_golden,
&[("p", &seat_label(*owner)), ("n", &format!("{points:+}"))],
),
palette::GOLD,
)),
SimEvent::LureStarted { owner } => Some((
fill(tr.log_lure, &[("p", &seat_label(*owner))]),
palette::INK_LURE,
)),
SimEvent::TierUp { owner } => Some((
fill(tr.log_tier, &[("p", &seat_label(*owner))]),
palette::seat_text(*owner),
)),
SimEvent::TideEventFired { event, by } => {
Some(event_line(tr, *event, *by, &seat_label))
}
SimEvent::WheelLocked { owner } => Some((
fill(tr.log_wheel_locked, &[("p", &seat_label(*owner))]),
palette::INK_TIDE,
)),
SimEvent::LureRefused { owner, running } => Some((
fill(
if *running {
tr.log_lure_running
} else {
tr.log_lure_resting
},
&[("p", &seat_label(*owner))],
),
palette::INK_LURE,
)),
SimEvent::GullIncoming { .. } => None,
SimEvent::GullArrived => Some((tr.log_gull.to_string(), palette::GULL_INK)),
SimEvent::SurgeStarted => Some((tr.log_surge.to_string(), palette::INK_SURGE)),
SimEvent::GoldenCalled { .. } => Some((tr.log_golden_call.to_string(), palette::GOLD)),
SimEvent::GoldenOut { .. } => Some((tr.log_golden_out.to_string(), palette::GOLD)),
SimEvent::CrabBanked { .. }
| SimEvent::CrabEaten { .. }
| SimEvent::CrabSpawned { .. }
| SimEvent::GullTookOff
| SimEvent::GullLanded { .. }
| SimEvent::GullShooed { .. }
| SimEvent::GullFlewOff { .. }
| SimEvent::SignpostPlaced { .. }
| SimEvent::SignpostRemoved { .. }
| SimEvent::SignpostEvicted { .. }
| SimEvent::RoundEnded => None,
};
if let Some((text, color)) = line {
log.push(text, color);
}
}
}
#[derive(Component)]
pub struct LogLines;
fn lines_that_fit(heights: impl IntoIterator<Item = f32>, gap: f32, room: f32) -> usize {
let mut used = 0.0;
let mut fit = 0;
for height in heights {
let next = if fit == 0 {
height
} else {
used + gap + height
};
if next > room + 0.5 {
break;
}
used = next;
fit += 1;
}
fit
}
pub fn update_log(
log: Res<EventLog>,
mut lines: Query<(&LogLine, &mut Text, &mut TextColor, &ComputedNode)>,
boxes: Query<&ComputedNode, With<LogLines>>,
mut value: Local<String>,
mut heights: Local<Vec<f32>>,
) {
let shown = boxes.single().map_or(LOG_LINES, |room| {
heights.clear();
heights.resize(LOG_LINES, 0.0);
for (line, _, _, node) in &lines {
if let Some(height) = heights.get_mut(line.0) {
*height = node.size.y;
}
}
let gap = LOG_GAP / room.inverse_scale_factor;
let filled = log.0.len().min(LOG_LINES);
lines_that_fit(heights[..filled].iter().copied(), gap, room.size.y)
});
for (line, mut text, mut color, _) in &mut lines {
let target = match log.0.get(line.0) {
Some(entry) => {
let fade = (1.0 - line.0 as f32 * 0.09).max(LOG_FADE_FLOOR);
entry.render_into(&mut value);
if line.0 < shown {
entry.color.with_alpha(fade)
} else {
Color::NONE
}
}
None => {
value.clear();
Color::NONE
}
};
menu_ui::set_text(&mut text, &value);
menu_ui::set_color(&mut color, target);
}
}
const LOG_ROWS: f32 = 8.0;
const LOG_GAP: f32 = 6.0;
const LOG_FADE_FLOOR: f32 = 0.3;
const LOG_H: f32 = LOG_ROWS * menu_ui::type_scale::BODY * menu_ui::LINE_HEIGHT
+ (LOG_ROWS - 1.0) * LOG_GAP
+ 2.0 * menu_ui::CARD_PAD
+ 2.0 * CARD_BORDER;
#[derive(Component)]
pub struct FeedCard;
pub(super) fn spawn_feed(root: &mut ChildSpawnerCommands) {
let (mut node, edge, fill, shadow) = card(LOG_TOP, Some(LOG_H));
node.flex_direction = FlexDirection::Column;
node.align_items = AlignItems::default();
node.justify_content = JustifyContent::default();
node.padding = UiRect::axes(Val::Px(super::event_timer::FEED_PAD_X), Val::Px(10.0));
node.padding.bottom = Val::Px(menu_ui::CARD_PAD);
let lines = Node {
flex_direction: FlexDirection::Column,
flex_grow: 1.0,
min_height: Val::Px(0.0),
row_gap: Val::Px(LOG_GAP),
overflow: Overflow::clip(),
..default()
};
root.spawn((FeedCard, node, edge, fill, shadow, menu_ui::Framed))
.with_children(|card| {
super::event_timer::spawn_event_timer(card);
card.spawn((LogLines, lines)).with_children(|list| {
for index in 0..LOG_LINES {
list.spawn((
LogLine(index),
Text::new(""),
TextFont {
font_size: FontSize::Px(menu_ui::type_scale::BODY),
..default()
},
TextColor(Color::NONE),
));
}
});
});
}
fn feed_height(foot: f32, room: f32) -> f32 {
let top = menu_ui::UNDER_HEADER + LOG_TOP;
(foot - top).min(room - top).max(LOG_H)
}
pub fn reach_the_board(
sim: Res<crate::app::Sim>,
screen: Res<State<crate::app::Screen>>,
ui_scale: Res<UiScale>,
windows: Query<&Window>,
mut cards: Query<&mut Node, With<FeedCard>>,
) {
let Ok(window) = windows.single() else {
return;
};
let ui = ui_scale.0.max(f32::EPSILON);
let foot = crate::app::boot::board_foot(window, ui, *screen.get(), &sim.0);
let room = window.height() / ui - super::SIDEBAR_FOOT;
let height = Val::Px(feed_height(foot, room));
for mut node in &mut cards {
if node.height != height {
node.height = height;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_feed_reaches_down_to_the_board() {
let top = menu_ui::UNDER_HEADER + LOG_TOP;
assert_eq!(feed_height(top + 500.0, top + 600.0), 500.0);
assert_eq!(
feed_height(top + 10.0, top + 600.0),
LOG_H,
"never under its rows"
);
assert_eq!(
feed_height(top + 900.0, top + 600.0),
600.0,
"never past the sidebar"
);
}
#[test]
fn only_whole_lines_are_drawn() {
assert_eq!(lines_that_fit([20.0, 20.0, 20.0], 5.0, 70.0), 3);
assert_eq!(lines_that_fit([20.0, 20.0, 20.0], 5.0, 69.0), 2);
assert_eq!(lines_that_fit([40.0, 20.0, 10.0], 5.0, 60.0), 1);
assert_eq!(lines_that_fit([], 5.0, 60.0), 0);
assert_eq!(lines_that_fit([80.0], 5.0, 60.0), 0, "one too tall");
}
#[test]
fn repeated_lines_merge_with_a_counter() {
let mut log = EventLog::default();
for _ in 0..9 {
log.push("a gull swoops in".into(), Color::WHITE);
}
assert_eq!(log.0.len(), 1);
assert_eq!(log.0[0].rendered(), "a gull swoops in ×9");
log.push("P1 raided! -4".into(), Color::WHITE);
assert_eq!(log.0[0].rendered(), "P1 raided! -4");
log.push("a gull swoops in".into(), Color::WHITE);
assert_eq!(log.0.len(), 2, "the gull line was bumped, not duplicated");
assert_eq!(log.0[0].rendered(), "a gull swoops in ×10");
}
#[test]
fn distinct_lines_scroll_and_stay_bounded() {
let mut log = EventLog::default();
for n in 0..LOG_LINES + 5 {
log.push(format!("line {n}"), Color::WHITE);
}
assert_eq!(log.0.len(), LOG_LINES);
assert_eq!(log.0[0].rendered(), format!("line {}", LOG_LINES + 4));
}
#[test]
fn a_raid_line_names_the_steerer() {
let tr = &crate::app::i18n::EN;
let name = |seat: u8| ["Maya", "Bob"][usize::from(seat)].to_string();
assert_eq!(
raid_line(tr, 1, 23, Some(0), &name),
"Maya's gull raided Bob! -23"
);
assert_eq!(
raid_line(tr, 1, 23, Some(1), &name),
"Bob steered a gull into their own castle! -23"
);
assert_eq!(raid_line(tr, 1, 23, None, &name), "Bob raided! -23");
}
}