pub mod achievements;
pub mod biscuit;
pub mod border;
pub mod debug_pane;
pub mod effects;
pub mod hands;
pub mod prestige;
pub mod sidebar;
pub mod stats;
pub mod toast;
pub mod tree;
use ratatui::{prelude::*, widgets::*};
use crate::format;
use crate::game::state::{Buff, GameState, HUD_FLASH_TICKS, TICK_HZ};
use crate::i18n::t;
const VERSION: &str = env!("CARGO_PKG_VERSION");
pub(crate) fn hud_title() -> String {
if VERSION == "0.0.0" {
match crate::build_info::GIT_BRANCH {
Some(branch) => format!(" CuqueClicker v0.0.0 (dev, {branch}) "),
None => " CuqueClicker v0.0.0 (dev) ".into(),
}
} else {
format!(" CuqueClicker v{VERSION} ")
}
}
#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum Mode {
Game,
Stats,
Achievements,
Tree,
Prestige,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TreeButtonAction {
Buy,
Refund,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum HelpAction {
OpenMode(Mode),
GrabGolden,
Quit,
TreeFocusOrigin,
TreeFocusLastBought,
}
#[derive(Default)]
pub struct DrawOutput {
pub biscuit_rect: Rect,
pub biscuit_focal: (u16, u16),
pub powerup_rects: Vec<(u64, Rect)>,
pub play_area: Rect,
pub tree_node_rects: Vec<(crate::game::tree::coord::TreeCoord, Rect)>,
pub tree_action_button: Option<(TreeButtonAction, Rect, crate::game::tree::coord::TreeCoord)>,
pub fingerer_rows: Vec<(usize, Rect)>,
pub help_hits: Vec<(HelpAction, Rect)>,
pub prestige_reset_rect: Rect,
pub prestige_confirm_yes_rect: Rect,
pub prestige_confirm_no_rect: Rect,
}
fn wrapped_height(text: &str, width: u16) -> u16 {
if width == 0 {
return text.lines().count().max(1) as u16;
}
let mut total: u16 = 0;
for line in text.split('\n') {
let mut row_len: u16 = 0;
let mut rows: u16 = 1;
for word in line.split_whitespace() {
let wlen = word.chars().count() as u16;
if row_len == 0 {
row_len = wlen.min(width);
} else if row_len + 1 + wlen <= width {
row_len += 1 + wlen;
} else {
rows += 1;
row_len = wlen.min(width);
}
}
total = total.saturating_add(rows);
}
total.max(1)
}
fn draw_zoom_indicator(frame: &mut Frame, area: Rect, label: &str) {
let text = format!("zoom {}", label);
let w = text.chars().count() as u16;
if area.width < w || area.height == 0 {
return;
}
let col = area.x + area.width - w;
let row = area.y + area.height - 1;
let buf = frame.buffer_mut();
buf.set_string(
col,
row,
&text,
Style::default().fg(Color::Rgb(120, 120, 120)),
);
}
#[allow(clippy::too_many_arguments)]
pub fn draw(
frame: &mut Frame,
state: &GameState,
mode: Mode,
zoom_idx: usize,
debug: bool,
mouse_pos: Option<(u16, u16)>,
tree_render: &mut crate::input::TreeRenderState,
prestige_confirm_pending: bool,
) -> DrawOutput {
let lang = t();
let area = frame.area();
let cols = Layout::horizontal([Constraint::Min(1), Constraint::Length(38)]).split(area);
let help_text = match mode {
Mode::Game => lang.help_game,
Mode::Stats => lang.help_stats,
Mode::Achievements => lang.help_ach,
Mode::Tree => lang.help_tree,
Mode::Prestige => lang.help_prestige,
};
let help_height = wrapped_height(help_text, cols[0].width).max(1);
let left = Layout::vertical([
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(help_height),
])
.split(cols[0]);
let gain_t = (state.cuques_flash_ticks as f32 / HUD_FLASH_TICKS as f32).clamp(0.0, 1.0);
let spend_t = (state.cuques_spend_flash_ticks as f32 / HUD_FLASH_TICKS as f32).clamp(0.0, 1.0);
const FLASH_GAIN: (f32, f32, f32) = (80.0, 255.0, 80.0); const FLASH_SPEND: (f32, f32, f32) = (255.0, 90.0, 90.0); const FLASH_REST: (f32, f32, f32) = (255.0, 255.0, 255.0);
let (peak, t) = if spend_t > gain_t {
(FLASH_SPEND, spend_t)
} else {
(FLASH_GAIN, gain_t)
};
let mix = 1.0 - t;
let r = peak.0 + (FLASH_REST.0 - peak.0) * mix;
let g = peak.1 + (FLASH_REST.1 - peak.1) * mix;
let b = peak.2 + (FLASH_REST.2 - peak.2) * mix;
let cuques_style = Style::default()
.fg(Color::Rgb(
r.clamp(0.0, 255.0) as u8,
g.clamp(0.0, 255.0) as u8,
b.clamp(0.0, 255.0) as u8,
))
.add_modifier(Modifier::BOLD);
let mut hud_spans: Vec<Span> = vec![
Span::raw(format!("{}: ", lang.hud_cuques)),
Span::styled(format::big_mag(state.displayed_cuques), cuques_style),
Span::raw(format!(
" {}: {}",
lang.hud_fps,
format::rate(state.displayed_fps)
)),
];
if state.prestige > 0 {
hud_spans.push(Span::styled(
format!(
" {}: {} (+{:.0}%)",
lang.prestige_title.trim(),
state.prestige,
state.prestige as f64
),
Style::default()
.fg(Color::Rgb(255, 215, 0))
.add_modifier(Modifier::BOLD),
));
}
for b in &state.buffs {
let secs = b.ticks_remaining().div_ceil(TICK_HZ);
let (label, color) = match b {
Buff::ClickFrenzy { .. } => {
(format!(" [!! FRENZY {}s]", secs), Color::Rgb(255, 80, 80))
}
};
hud_spans.push(Span::styled(
label,
Style::default().fg(color).add_modifier(Modifier::BOLD),
));
}
for (id, st) in &state.fingerers_state {
for m in &st.modifiers {
let crate::game::modifier::ModifierDuration::Ticks(remaining) = m.duration else {
continue;
};
let secs = remaining.div_ceil(TICK_HZ);
let idx = crate::game::fingerer::FINGERERS
.iter()
.position(|f| f.id == id);
let name = idx
.and_then(|i| lang.fingerer_names.get(i).copied())
.unwrap_or("?");
let mul = m.effects.iter().find_map(|e| match e {
crate::game::modifier::ModifierEffect::MulFactor(v) => Some(*v),
_ => None,
});
let label = match mul {
Some(v) => format!(" [++ {} x{} {}s]", name, v.floor_u64(), secs),
None => format!(" [++ {} {}s]", name, secs),
};
let color = match m.source {
crate::game::modifier::ModifierSource::PurpleCoin => Color::Rgb(220, 140, 255),
crate::game::modifier::ModifierSource::GreenCoin => Color::Rgb(120, 230, 140),
};
hud_spans.push(Span::styled(
label,
Style::default().fg(color).add_modifier(Modifier::BOLD),
));
}
}
let title = hud_title();
border::draw_animated(frame, left[0], state, &title);
let hud_inner = Rect {
x: left[0].x + 1,
y: left[0].y + 1,
width: left[0].width.saturating_sub(2),
height: left[0].height.saturating_sub(2),
};
let hud = Paragraph::new(Line::from(hud_spans));
frame.render_widget(hud, hud_inner);
let biscuit_rect = biscuit::draw(frame, left[1], state, zoom_idx);
let biscuit_focal = biscuit::focal_point(zoom_idx, biscuit_rect);
hands::draw(frame, left[1], biscuit_rect, biscuit_focal, state);
effects::draw_particles(frame, biscuit_rect, &state.particles);
effects::draw_misclicks(frame, &state.misclick_particles);
if mode != Mode::Tree {
draw_zoom_indicator(
frame,
left[1],
biscuit::level_label(zoom_idx).unwrap_or("100%"),
);
}
if debug && mode != Mode::Tree {
debug_pane::draw(frame, left[1]);
}
let mut powerup_rects: Vec<(u64, Rect)> = Vec::with_capacity(state.powerups.len());
for p in &state.powerups {
let r = biscuit::draw_powerup(frame, p, biscuit_rect);
powerup_rects.push((p.spawn_id, r));
}
toast::draw(frame, left[1], state);
let help_hits = draw_help(frame, left[2], help_text, mode, mouse_pos);
let mut tree_node_rects: Vec<(crate::game::tree::coord::TreeCoord, Rect)> = Vec::new();
let mut tree_action_button: Option<(
TreeButtonAction,
Rect,
crate::game::tree::coord::TreeCoord,
)> = None;
let mut fingerer_rows: Vec<(usize, Rect)> = Vec::new();
let mut prestige_reset_rect = Rect::default();
let mut prestige_confirm_yes_rect = Rect::default();
let mut prestige_confirm_no_rect = Rect::default();
match mode {
Mode::Game => fingerer_rows = sidebar::draw(frame, cols[1], state, mouse_pos),
Mode::Stats => stats::draw(frame, cols[1], state),
Mode::Achievements => achievements::draw(frame, cols[1], state),
Mode::Tree => {
let out = tree::draw(frame, area, state, mouse_pos, tree_render);
tree_node_rects = out.node_rects;
tree_action_button = out.action_button;
}
Mode::Prestige => {
let rects = prestige::draw(frame, cols[1], state, mouse_pos, prestige_confirm_pending);
prestige_reset_rect = rects.reset;
prestige_confirm_yes_rect = rects.yes;
prestige_confirm_no_rect = rects.no;
}
}
DrawOutput {
biscuit_rect,
biscuit_focal,
powerup_rects,
play_area: left[1],
tree_node_rects,
tree_action_button,
fingerer_rows,
help_hits,
prestige_reset_rect,
prestige_confirm_yes_rect,
prestige_confirm_no_rect,
}
}
fn draw_help(
frame: &mut Frame,
area: Rect,
text: &str,
mode: Mode,
mouse_pos: Option<(u16, u16)>,
) -> Vec<(HelpAction, Rect)> {
let mut hits: Vec<(HelpAction, Rect)> = Vec::new();
if area.width == 0 || area.height == 0 {
return hits;
}
let buf = frame.buffer_mut();
let mut cursor_x: u16 = 0;
let mut cursor_y: u16 = 0;
for line in text.split('\n') {
for token in line.split(" ") {
let token = token.trim();
if token.is_empty() {
continue;
}
let w = token.chars().count() as u16;
if cursor_x + w > area.width && cursor_x > 0 {
cursor_y += 1;
cursor_x = 0;
}
if cursor_y >= area.height {
break;
}
let action = map_help_token(token, mode);
if matches!(action, Some(HelpAction::Quit)) && !crate::platform::CAPABILITIES.can_quit {
continue;
}
let active = matches!(action, Some(HelpAction::OpenMode(m)) if m == mode);
let token_rect = Rect {
x: area.x + cursor_x,
y: area.y + cursor_y,
width: w.min(area.width.saturating_sub(cursor_x)),
height: 1,
};
let hovered = action.is_some()
&& mouse_pos
.map(|(mx, my)| {
mx >= token_rect.x
&& mx < token_rect.x + token_rect.width
&& my == token_rect.y
})
.unwrap_or(false);
let mut style = if active {
Style::default()
.fg(Color::Rgb(255, 220, 120))
.add_modifier(Modifier::BOLD)
} else if action.is_some() {
Style::default()
.fg(Color::Rgb(180, 180, 180))
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
if hovered {
style = style
.fg(Color::Rgb(255, 255, 255))
.bg(Color::Rgb(40, 40, 50))
.add_modifier(Modifier::BOLD);
}
buf.set_string(token_rect.x, token_rect.y, token, style);
if let Some(a) = action {
hits.push((a, token_rect));
}
cursor_x += w + 2; }
cursor_y += 1;
cursor_x = 0;
if cursor_y >= area.height {
break;
}
}
hits
}
fn map_help_token(token: &str, mode: Mode) -> Option<HelpAction> {
let open = token.find('[')?;
let close = token[open + 1..].find(']')? + open + 1;
let key = &token[open + 1..close];
if key.eq_ignore_ascii_case("q") {
return Some(HelpAction::Quit);
}
if mode != Mode::Game && (key.contains("Esc") || key.contains("esc")) {
return Some(HelpAction::OpenMode(Mode::Game));
}
match (mode, key) {
(Mode::Game, "t") | (Mode::Game, "T") => Some(HelpAction::OpenMode(Mode::Tree)),
(Mode::Game, "p") | (Mode::Game, "P") => Some(HelpAction::OpenMode(Mode::Prestige)),
(Mode::Game, "s") | (Mode::Game, "S") => Some(HelpAction::OpenMode(Mode::Stats)),
(Mode::Game, "a") | (Mode::Game, "A") => Some(HelpAction::OpenMode(Mode::Achievements)),
(Mode::Game, "g") | (Mode::Game, "G") => Some(HelpAction::GrabGolden),
(Mode::Tree, "0") => Some(HelpAction::TreeFocusOrigin),
(Mode::Tree, "1") => Some(HelpAction::TreeFocusLastBought),
_ => None,
}
}